feat(lunar): 双端农历换算层(Go + TS,同作者同算法)
日历要支持「每农历月十五」「农历生日」这类规则。公历与农历的换算不能
自己算,两端各引一个库:Go 用 6tail/lunar-go v1.4.6,前端用同作者的
lunar-javascript 1.7.7 —— 同算法保证两端结果一致(前端要在格子上显示
农历日、在编辑器里预览接下来几次触发)。
为什么要包一层而不直接用库:
**1. 库在非法日期上 panic 而不是返回 error。**
`NewLunarFromYmd(2027, 9, 30)` 直接 panic("only 29 days in lunar year
2027 month 9")。农历月是 29 或 30 天不定,「每月农历三十」这条规则必然
撞上短月份。调度器里一次 panic 就让那条提醒永久卡住。
修法是夹到该月实际天数并返回 clamped 标记 —— 夹而不滚:「每月三十」的
语义是「月末那天」,滚到下月初一会让提醒与前一次只隔一天。
**2. 闰月用负数月份表示**(-6 = 闰六月),这个约定藏在库内部。
2025 有闰六月、2028 有闰五月,2026/2027 没有。AddYears 从闰月出发而
目标年没有同一闰月时退回正月份 —— 静默让重复事件消失更糟。
**3. 按农历推进不能加固定天数。**
农历月 29~30 天、农历年 353~385 天(闰年多一整月),AddDate 近似一年
能偏半个月。
前端另有一个 TS 陷阱:日名有五种前缀形态(初一/十一/二十/廿一/三十),
原来用正则从 toString() 截取时漏了「二十」,20 号会显示整串「七月二十」。
改成查表。
测试:Go 12 例 / TS 37 例。含「同一农历日在六年公历里落到至少 4 个不同
月日上」—— 那正是农历重复存在的理由(公历 yearly 会固定在同一天)。
This commit is contained in:
@ -12,6 +12,7 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/6tail/lunar-go v1.4.6 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
github.com/6tail/lunar-go v1.4.6 h1:APCXi1PC3Q7gZt6RJyug/ZdZcwX2qOkzIsZIcjCQdHY=
|
||||||
|
github.com/6tail/lunar-go v1.4.6/go.mod h1:mMvCby9aWTSmsZjnv+5EOW7taJFV4RsjNcQLRl/3whY=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
|||||||
205
gateway/internal/lunar/lunar.go
Normal file
205
gateway/internal/lunar/lunar.go
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
// Package lunar 把公历与农历互转,并提供「按农历推进」的重复规则计算。
|
||||||
|
//
|
||||||
|
// 为什么要单独一层而不直接用 lunar-go:
|
||||||
|
//
|
||||||
|
// 1. **lunar-go 在非法日期上 panic 而不是返回 error**。
|
||||||
|
// `NewLunarFromYmd(2027, 9, 30)` 直接 panic("only 29 days in lunar
|
||||||
|
// year 2027 month 9") —— 农历月是 29 或 30 天不定,「每月农历三十」
|
||||||
|
// 这条规则必然会撞上 29 天的月份。调度器里一次 panic 就让那一轮所有
|
||||||
|
// 提醒全部落空(虽然有 recover 兜底,但结果是那一条提醒永久卡住)。
|
||||||
|
//
|
||||||
|
// 2. **闰月用负数月份表示**(-6 = 闰六月),这个约定藏在库内部。
|
||||||
|
// 2025 有闰六月、2028 有闰五月,而 2026/2027 没有 —— 「每年农历某月
|
||||||
|
// 某日」跨过闰月年份时必须决定落在哪个月,这个决策不该散落在 repo 里。
|
||||||
|
//
|
||||||
|
// 3. **按农历推进不能靠加固定天数**。农历月 29~30 天、农历年 353~385 天
|
||||||
|
// (闰年多一个月)。用 AddDate 近似会越推越偏,一年下来能差半个月。
|
||||||
|
package lunar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/6tail/lunar-go/calendar"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Date 是一个农历日期。
|
||||||
|
//
|
||||||
|
// Month 为负数表示闰月(-6 = 闰六月),与 lunar-go 的约定一致 ——
|
||||||
|
// 刻意沿用而不另造一个 IsLeap bool:两种表示混用时转换处极易写反,
|
||||||
|
// 而负数在数值比较里天然排在正数前面(闰六月在六月之后,需要注意这一点,
|
||||||
|
// 见 monthsInYear 的排序)。
|
||||||
|
type Date struct {
|
||||||
|
Year int
|
||||||
|
Month int // 负数 = 闰月
|
||||||
|
Day int
|
||||||
|
}
|
||||||
|
|
||||||
|
// FromSolar 把公历时刻转成农历日期。
|
||||||
|
//
|
||||||
|
// 只取年月日,时分秒由调用方保留 —— 农历只定义到「日」,
|
||||||
|
// 「农历七月十五早上九点」的「九点」是公历时钟的概念。
|
||||||
|
func FromSolar(t time.Time) Date {
|
||||||
|
s := calendar.NewSolarFromYmd(t.Year(), int(t.Month()), t.Day())
|
||||||
|
l := s.GetLunar()
|
||||||
|
return Date{Year: l.GetYear(), Month: l.GetMonth(), Day: l.GetDay()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToSolar 把农历日期转回公历,并带上给定的时分秒。
|
||||||
|
//
|
||||||
|
// **日期会被夹到该农历月的实际天数内**:请求农历三十而该月只有 29 天时
|
||||||
|
// 返回廿九,而不是 panic 也不是滚到下个月的初一。
|
||||||
|
//
|
||||||
|
// 夹而不滚的理由:「每月农历三十」的语义是「月末那天」,滚到下月初一会让
|
||||||
|
// 提醒出现在完全错误的日子(且与前一次提醒只隔一天)。
|
||||||
|
//
|
||||||
|
// 返回的 clamped 说明是否发生了夹取 —— 调用方据此决定是否要在 UI 上提示。
|
||||||
|
func (d Date) ToSolar(loc *time.Location, hour, min, sec, nsec int) (t time.Time, clamped bool, err error) {
|
||||||
|
days, err := DaysInMonth(d.Year, d.Month)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, false, err
|
||||||
|
}
|
||||||
|
day := d.Day
|
||||||
|
if day > days {
|
||||||
|
day = days
|
||||||
|
clamped = true
|
||||||
|
}
|
||||||
|
if day < 1 {
|
||||||
|
return time.Time{}, false, fmt.Errorf("农历日 %d 非法", d.Day)
|
||||||
|
}
|
||||||
|
|
||||||
|
// lunar-go 在非法输入上 panic,这里兜住转成 error:
|
||||||
|
// 上面已经夹过日期,理论上不会触发,但闰月不存在之类的组合仍可能进来。
|
||||||
|
var solar *calendar.Solar
|
||||||
|
func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
err = fmt.Errorf("农历 %d-%d-%d 无法转公历: %v", d.Year, d.Month, day, r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
solar = calendar.NewLunarFromYmd(d.Year, d.Month, day).GetSolar()
|
||||||
|
}()
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, false, err
|
||||||
|
}
|
||||||
|
if solar == nil {
|
||||||
|
return time.Time{}, false, fmt.Errorf("农历 %d-%d-%d 转公历得到空值", d.Year, d.Month, day)
|
||||||
|
}
|
||||||
|
|
||||||
|
return time.Date(solar.GetYear(), time.Month(solar.GetMonth()), solar.GetDay(),
|
||||||
|
hour, min, sec, nsec, loc), clamped, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DaysInMonth 返回某个农历月有多少天(29 或 30)。
|
||||||
|
//
|
||||||
|
// Month 为负数时查闰月。该年没有这个闰月则返回错误 ——
|
||||||
|
// 这不是异常情况:「每年农历闰六月十五」这条规则在没有闰六月的年份
|
||||||
|
// 本来就无法落地,调用方需要据此跳过而不是猜一个日子。
|
||||||
|
func DaysInMonth(year, month int) (int, error) {
|
||||||
|
var days int
|
||||||
|
var found bool
|
||||||
|
// LunarYear.GetMonths() 是 *list.List,元素为 *LunarMonth。
|
||||||
|
// 一个 LunarYear 对象里会带上跨年边界的月份,因此必须同时比对 year。
|
||||||
|
ly := calendar.NewLunarYear(year)
|
||||||
|
for e := ly.GetMonths().Front(); e != nil; e = e.Next() {
|
||||||
|
lm, ok := e.Value.(*calendar.LunarMonth)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if lm.GetYear() == year && lm.GetMonth() == month {
|
||||||
|
days = lm.GetDayCount()
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
if month < 0 {
|
||||||
|
return 0, fmt.Errorf("农历 %d 年没有闰 %d 月", year, -month)
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("农历 %d 年没有 %d 月", year, month)
|
||||||
|
}
|
||||||
|
return days, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LeapMonth 返回某农历年的闰月(0 = 无闰月)。
|
||||||
|
func LeapMonth(year int) int {
|
||||||
|
return calendar.NewLunarYear(year).GetLeapMonth()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddMonths 在农历上推进若干个月。
|
||||||
|
//
|
||||||
|
// 逐月走而不是「月份数 + n 再取模」:中间可能夹着闰月,
|
||||||
|
// 而闰月是否存在取决于年份,没有闭式公式。
|
||||||
|
//
|
||||||
|
// 闰月的处理:**推进时跳过闰月**。从六月推一个月得七月,不是闰六月。
|
||||||
|
// 理由是「每月十五」这类规则的用户期望是一年 12 次,
|
||||||
|
// 把闰月算进去会让闰年多出一次提醒 —— 那是农历年的性质,不是提醒的性质。
|
||||||
|
// 想要闰月本身的提醒应该用 lunar_yearly 指定 -6 月。
|
||||||
|
func (d Date) AddMonths(n int) Date {
|
||||||
|
y, m := d.Year, d.Month
|
||||||
|
// 从闰月出发时先归到对应的正月份:闰六月 +1 → 七月
|
||||||
|
if m < 0 {
|
||||||
|
m = -m
|
||||||
|
}
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
m++
|
||||||
|
if m > 12 {
|
||||||
|
m = 1
|
||||||
|
y++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Date{Year: y, Month: m, Day: d.Day}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddYears 在农历上推进若干年,月份与日期保持不变。
|
||||||
|
//
|
||||||
|
// 从闰月出发时(Month < 0)目标年没有同一个闰月,退回对应的正月份 ——
|
||||||
|
// 「去年闰六月十五」在今年最接近的对应日就是六月十五。
|
||||||
|
// 直接放弃(不再提醒)更糟:那是静默地让重复事件消失。
|
||||||
|
func (d Date) AddYears(n int) Date {
|
||||||
|
y := d.Year + n
|
||||||
|
m := d.Month
|
||||||
|
if m < 0 && LeapMonth(y) != -m {
|
||||||
|
m = -m
|
||||||
|
}
|
||||||
|
return Date{Year: y, Month: m, Day: d.Day}
|
||||||
|
}
|
||||||
|
|
||||||
|
// String 给出「二〇二六年七月廿二」这样的中文农历表示。
|
||||||
|
//
|
||||||
|
// UI 上必须显示它:农历事件的公历日期每年都在变,
|
||||||
|
// 只显示公历会让人无法确认「这条规则是不是我想的那个农历日子」。
|
||||||
|
func (d Date) String() string {
|
||||||
|
days, err := DaysInMonth(d.Year, d.Month)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("农历 %d 年%d月%d日(无效)", d.Year, d.Month, d.Day)
|
||||||
|
}
|
||||||
|
day := d.Day
|
||||||
|
if day > days {
|
||||||
|
day = days
|
||||||
|
}
|
||||||
|
var out string
|
||||||
|
func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
out = fmt.Sprintf("农历 %d-%d-%d", d.Year, d.Month, d.Day)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
out = calendar.NewLunarFromYmd(d.Year, d.Month, day).String()
|
||||||
|
}()
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatSolar 把公历时刻渲染成「2026-09-03(农历七月廿二)」。
|
||||||
|
//
|
||||||
|
// 给提醒正文与 UI 用:两种历都写出来,人才能确认规则没被理解错。
|
||||||
|
func FormatSolar(t time.Time) string {
|
||||||
|
d := FromSolar(t)
|
||||||
|
full := d.String()
|
||||||
|
// 去掉年份部分(「二〇二六年」共 4 个中文字符 + 「年」),只留月日 ——
|
||||||
|
// 公历年份已经在前面写了,重复一遍反而更难读。
|
||||||
|
if r := []rune(full); len(r) > 5 && r[4] == '年' {
|
||||||
|
full = string(r[5:])
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s(农历%s)", t.Format("2006-01-02"), full)
|
||||||
|
}
|
||||||
216
gateway/internal/lunar/lunar_test.go
Normal file
216
gateway/internal/lunar/lunar_test.go
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
package lunar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 已知对照点。农历换算错了不会报错,只会让提醒发在错误的日子,
|
||||||
|
// 因此必须钉住几个可人工核对的锚点。
|
||||||
|
func TestFromSolarKnownDates(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
solar string
|
||||||
|
year int
|
||||||
|
month int
|
||||||
|
day int
|
||||||
|
}{
|
||||||
|
{"2026-09-03", 2026, 7, 22},
|
||||||
|
{"2026-01-01", 2025, 11, 13},
|
||||||
|
// 2025 有闰六月:闰月里的日子 Month 应为负
|
||||||
|
{"2025-07-25", 2025, -6, 1},
|
||||||
|
{"2025-06-25", 2025, 6, 1},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
st, err := time.Parse("2006-01-02", c.solar)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("解析 %s: %v", c.solar, err)
|
||||||
|
}
|
||||||
|
got := FromSolar(st)
|
||||||
|
if got.Year != c.year || got.Month != c.month || got.Day != c.day {
|
||||||
|
t.Errorf("%s → 农历 %d-%d-%d,期望 %d-%d-%d",
|
||||||
|
c.solar, got.Year, got.Month, got.Day, c.year, c.month, c.day)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToSolarRoundtrip(t *testing.T) {
|
||||||
|
loc := time.Local
|
||||||
|
for _, solar := range []string{"2026-09-03", "2027-02-14", "2028-06-30", "2025-07-25"} {
|
||||||
|
st, _ := time.Parse("2006-01-02", solar)
|
||||||
|
d := FromSolar(st)
|
||||||
|
back, clamped, err := d.ToSolar(loc, 9, 30, 0, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("%s 往返失败: %v", solar, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if clamped {
|
||||||
|
t.Errorf("%s 往返不该发生夹取", solar)
|
||||||
|
}
|
||||||
|
if back.Format("2006-01-02") != solar {
|
||||||
|
t.Errorf("%s 往返得到 %s", solar, back.Format("2006-01-02"))
|
||||||
|
}
|
||||||
|
if back.Hour() != 9 || back.Minute() != 30 {
|
||||||
|
t.Errorf("%s 往返丢了时分:%v", solar, back)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 农历月是 29 或 30 天不定,「每月农历三十」必然撞上 29 天的月份。
|
||||||
|
// lunar-go 在这种输入上**panic** 而不是返回 error —— 必须被夹住。
|
||||||
|
func TestToSolarClampsShortMonth(t *testing.T) {
|
||||||
|
// 2027 农历九月只有 29 天
|
||||||
|
days, err := DaysInMonth(2027, 9)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("查天数: %v", err)
|
||||||
|
}
|
||||||
|
if days != 29 {
|
||||||
|
t.Fatalf("前提变了:2027 农历九月现在是 %d 天", days)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, clamped, err := Date{Year: 2027, Month: 9, Day: 30}.ToSolar(time.Local, 9, 0, 0, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("三十日应被夹到廿九而不是报错,得到 %v", err)
|
||||||
|
}
|
||||||
|
if !clamped {
|
||||||
|
t.Error("应报告发生了夹取")
|
||||||
|
}
|
||||||
|
// 夹取后应等于该月廿九
|
||||||
|
want, _, _ := Date{Year: 2027, Month: 9, Day: 29}.ToSolar(time.Local, 9, 0, 0, 0)
|
||||||
|
if !got.Equal(want) {
|
||||||
|
t.Errorf("夹取后 %v,期望与廿九相同 %v", got, want)
|
||||||
|
}
|
||||||
|
// 且必须仍在同一个农历月内 —— 滚到下月初一是错的
|
||||||
|
if FromSolar(got).Month != 9 {
|
||||||
|
t.Errorf("夹取后跑出了农历九月:%s", FromSolar(got).String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToSolarRejectsNonexistentLeapMonth(t *testing.T) {
|
||||||
|
// 2026 无闰月
|
||||||
|
if LeapMonth(2026) != 0 {
|
||||||
|
t.Fatalf("前提变了:2026 闰月 = %d", LeapMonth(2026))
|
||||||
|
}
|
||||||
|
_, _, err := Date{Year: 2026, Month: -6, Day: 1}.ToSolar(time.Local, 9, 0, 0, 0)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("不存在的闰月应返回错误而不是猜一个日子")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLeapMonth(t *testing.T) {
|
||||||
|
cases := map[int]int{2025: 6, 2026: 0, 2027: 0, 2028: 5}
|
||||||
|
for y, want := range cases {
|
||||||
|
if got := LeapMonth(y); got != want {
|
||||||
|
t.Errorf("LeapMonth(%d) = %d,期望 %d", y, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDaysInMonth(t *testing.T) {
|
||||||
|
if d, err := DaysInMonth(2026, 1); err != nil || d != 30 {
|
||||||
|
t.Errorf("2026 正月应 30 天,得到 %d err=%v", d, err)
|
||||||
|
}
|
||||||
|
if d, err := DaysInMonth(2027, 9); err != nil || d != 29 {
|
||||||
|
t.Errorf("2027 九月应 29 天,得到 %d err=%v", d, err)
|
||||||
|
}
|
||||||
|
if _, err := DaysInMonth(2026, -6); err == nil {
|
||||||
|
t.Error("2026 没有闰六月,应返回错误")
|
||||||
|
}
|
||||||
|
if _, err := DaysInMonth(2026, 13); err == nil {
|
||||||
|
t.Error("13 月应返回错误")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按农历推进不能靠加固定天数:农历月 29~30 天,闰年 13 个月。
|
||||||
|
func TestAddMonths(t *testing.T) {
|
||||||
|
d := Date{Year: 2026, Month: 7, Day: 22}
|
||||||
|
if got := d.AddMonths(1); got.Month != 8 || got.Year != 2026 {
|
||||||
|
t.Errorf("+1 月 = %d-%d,期望 2026-8", got.Year, got.Month)
|
||||||
|
}
|
||||||
|
// 跨年
|
||||||
|
if got := (Date{Year: 2026, Month: 12, Day: 5}).AddMonths(1); got.Year != 2027 || got.Month != 1 {
|
||||||
|
t.Errorf("腊月 +1 = %d-%d,期望 2027-1", got.Year, got.Month)
|
||||||
|
}
|
||||||
|
// 推 12 次回到同月次年
|
||||||
|
if got := d.AddMonths(12); got.Year != 2027 || got.Month != 7 {
|
||||||
|
t.Errorf("+12 月 = %d-%d,期望 2027-7", got.Year, got.Month)
|
||||||
|
}
|
||||||
|
// 从闰月出发先归正:闰六月 +1 → 七月(一年 12 次,不因闰年多一次)
|
||||||
|
if got := (Date{Year: 2025, Month: -6, Day: 15}).AddMonths(1); got.Month != 7 {
|
||||||
|
t.Errorf("闰六月 +1 = %d 月,期望 7 月", got.Month)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddYears(t *testing.T) {
|
||||||
|
if got := (Date{Year: 2026, Month: 7, Day: 22}).AddYears(1); got.Year != 2027 || got.Month != 7 {
|
||||||
|
t.Errorf("+1 年 = %d-%d", got.Year, got.Month)
|
||||||
|
}
|
||||||
|
// 从闰月出发、目标年没有同一闰月 → 退回正月份而不是静默消失
|
||||||
|
got := (Date{Year: 2025, Month: -6, Day: 15}).AddYears(1)
|
||||||
|
if got.Year != 2026 || got.Month != 6 {
|
||||||
|
t.Errorf("闰六月 +1 年 = %d-%d,期望 2026-6(退回正六月)", got.Year, got.Month)
|
||||||
|
}
|
||||||
|
// 目标年恰好也有同一闰月 → 保持闰月
|
||||||
|
// 2025 闰六月 → 2028 闰五月,所以这里构造 2028 的闰五月 +0 年
|
||||||
|
if LeapMonth(2028) == 5 {
|
||||||
|
keep := (Date{Year: 2028, Month: -5, Day: 1}).AddYears(0)
|
||||||
|
if keep.Month != -5 {
|
||||||
|
t.Errorf("目标年有同一闰月时应保持,得到 %d", keep.Month)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 「每年农历某月某日」的公历日期每年都在漂移 —— 这正是需要农历重复的理由。
|
||||||
|
// 如果用公历 yearly,日子会固定,与用户的期望(过农历生日/祭日)不符。
|
||||||
|
func TestYearlyLunarDriftsInSolar(t *testing.T) {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
d := Date{Year: 2026, Month: 7, Day: 22}
|
||||||
|
for i := 0; i < 6; i++ {
|
||||||
|
st, _, err := d.AddYears(i).ToSolar(time.Local, 9, 0, 0, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("第 %d 年转换失败: %v", i, err)
|
||||||
|
}
|
||||||
|
seen[st.Format("01-02")] = true
|
||||||
|
}
|
||||||
|
if len(seen) < 4 {
|
||||||
|
t.Errorf("六年里公历月日只有 %d 种,农历重复应当漂移", len(seen))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStringChinese(t *testing.T) {
|
||||||
|
got := (Date{Year: 2026, Month: 7, Day: 22}).String()
|
||||||
|
if got != "二〇二六年七月廿二" {
|
||||||
|
t.Errorf("String() = %q,期望 二〇二六年七月廿二", got)
|
||||||
|
}
|
||||||
|
// 闰月要带「闰」字,否则人分不清是哪个月
|
||||||
|
leap := (Date{Year: 2025, Month: -6, Day: 1}).String()
|
||||||
|
if leap != "二〇二五年闰六月初一" {
|
||||||
|
t.Errorf("闰月 String() = %q", leap)
|
||||||
|
}
|
||||||
|
// 非法日期不 panic
|
||||||
|
bad := (Date{Year: 2026, Month: 13, Day: 1}).String()
|
||||||
|
if bad == "" {
|
||||||
|
t.Error("非法日期应返回可读文本而不是空串")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatSolar(t *testing.T) {
|
||||||
|
st, _ := time.Parse("2006-01-02", "2026-09-03")
|
||||||
|
got := FormatSolar(st)
|
||||||
|
if got != "2026-09-03(农历七月廿二)" {
|
||||||
|
t.Errorf("FormatSolar = %q,期望 2026-09-03(农历七月廿二)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 时区:农历只定义到「日」,时分秒是公历时钟的概念,必须原样带过去。
|
||||||
|
func TestToSolarKeepsClockTime(t *testing.T) {
|
||||||
|
got, _, err := (Date{Year: 2026, Month: 7, Day: 22}).ToSolar(time.Local, 14, 45, 30, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("转换: %v", err)
|
||||||
|
}
|
||||||
|
if got.Hour() != 14 || got.Minute() != 45 || got.Second() != 30 {
|
||||||
|
t.Errorf("时钟被改动:%v", got)
|
||||||
|
}
|
||||||
|
if got.Location() != time.Local {
|
||||||
|
t.Errorf("时区被改动:%v", got.Location())
|
||||||
|
}
|
||||||
|
}
|
||||||
7
web/package-lock.json
generated
7
web/package-lock.json
generated
@ -8,6 +8,7 @@
|
|||||||
"name": "agentmail-web",
|
"name": "agentmail-web",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"lunar-javascript": "1.7.7",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-markdown": "^9.0.1",
|
"react-markdown": "^9.0.1",
|
||||||
@ -2957,6 +2958,12 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lunar-javascript": {
|
||||||
|
"version": "1.7.7",
|
||||||
|
"resolved": "https://registry.npmmirror.com/lunar-javascript/-/lunar-javascript-1.7.7.tgz",
|
||||||
|
"integrity": "sha512-u/KYiwPIBo/0bT+WWfU7qO1d+aqeB90Tuy4ErXenr2Gam0QcWeezUvtiOIyXR7HbVnW2I1DKfU0NBvzMZhbVQw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lz-string": {
|
"node_modules/lz-string": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmmirror.com/lz-string/-/lz-string-1.5.0.tgz",
|
"resolved": "https://registry.npmmirror.com/lz-string/-/lz-string-1.5.0.tgz",
|
||||||
|
|||||||
@ -8,13 +8,14 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "node test/markdown-xss.test.mjs && node test/narrow-layout.test.mjs && vitest run",
|
"test": "node test/markdown-xss.test.mjs && node test/narrow-layout.test.mjs && node test/theme.test.mjs && vitest run",
|
||||||
"test:narrow": "node test/manual/narrow-verify.mjs",
|
"test:narrow": "node test/manual/narrow-verify.mjs",
|
||||||
"test:wide": "node test/manual/wide-regression.mjs",
|
"test:wide": "node test/manual/wide-regression.mjs",
|
||||||
"test:components": "vitest run",
|
"test:components": "vitest run",
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"lunar-javascript": "1.7.7",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-markdown": "^9.0.1",
|
"react-markdown": "^9.0.1",
|
||||||
|
|||||||
327
web/src/lib/lunar.ts
Normal file
327
web/src/lib/lunar.ts
Normal file
@ -0,0 +1,327 @@
|
|||||||
|
import { Solar, Lunar, LunarYear } from 'lunar-javascript';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 农历换算与「按农历推进」的重复规则计算。
|
||||||
|
*
|
||||||
|
* 这一层是后端 `internal/lunar/lunar.go` 的镜像 —— 两边用的是同一作者
|
||||||
|
* (6tail)的库(lunar-javascript / lunar-go),换算结果一致。
|
||||||
|
* 前端需要它是因为**日历格子上要显示农历日**,而且事件编辑器要在保存前
|
||||||
|
* 预览「这条规则接下来几次落在哪天」。让后端算再拉一次网络请求太慢,
|
||||||
|
* 而完全不显示农历会让人无法确认规则没被理解错。
|
||||||
|
*
|
||||||
|
* 与后端保持同步的两条硬约定:
|
||||||
|
*
|
||||||
|
* 1. **闰月用负数月份表示**(-6 = 闰六月)。
|
||||||
|
* 2. **非法日期必须夹取而不是抛错**。库在 `Lunar.fromYmd(2027, 9, 30)`
|
||||||
|
* 上直接 throw(农历月是 29 或 30 天不定),「每月农历三十」这条规则
|
||||||
|
* 必然撞上 29 天的月份。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 一个农历日期。month 为负数表示闰月。 */
|
||||||
|
export interface LunarDate {
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
day: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 公历 Date → 农历日期(只取年月日)。 */
|
||||||
|
export function fromSolar(d: Date): LunarDate {
|
||||||
|
const l = Solar.fromYmd(d.getFullYear(), d.getMonth() + 1, d.getDate()).getLunar();
|
||||||
|
return { year: l.getYear(), month: l.getMonth(), day: l.getDay() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 某农历年的闰月(0 = 无闰月)。 */
|
||||||
|
export function leapMonth(year: number): number {
|
||||||
|
try {
|
||||||
|
return LunarYear.fromYear(year).getLeapMonth();
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 某个农历月有多少天(29 或 30)。
|
||||||
|
*
|
||||||
|
* 返回 0 表示该月不存在(例如问一个没有闰六月的年份要闰六月)——
|
||||||
|
* 这不是异常:「每年农历闰六月十五」在无闰六月的年份本来就无法落地,
|
||||||
|
* 调用方需要据此跳过而不是猜一个日子。
|
||||||
|
*/
|
||||||
|
export function daysInMonth(year: number, month: number): number {
|
||||||
|
try {
|
||||||
|
const ly = LunarYear.fromYear(year);
|
||||||
|
const months = ly.getMonths();
|
||||||
|
for (const lm of months) {
|
||||||
|
if (lm.getYear() === year && lm.getMonth() === month) {
|
||||||
|
return lm.getDayCount();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 农历日期 → 公历 Date,带上给定的时分秒。
|
||||||
|
*
|
||||||
|
* 日期会被**夹到该农历月的实际天数内**:请求农历三十而该月只有 29 天时
|
||||||
|
* 返回廿九,而不是抛错也不是滚到下个月初一。
|
||||||
|
*
|
||||||
|
* 夹而不滚:「每月农历三十」的语义是「月末那天」,滚到下月初一会让提醒
|
||||||
|
* 出现在完全错误的日子(且与前一次只隔一天)。
|
||||||
|
*
|
||||||
|
* 返回 null 表示该农历月根本不存在(无效的闰月)。
|
||||||
|
*/
|
||||||
|
export function toSolar(
|
||||||
|
d: LunarDate,
|
||||||
|
hour = 0,
|
||||||
|
minute = 0,
|
||||||
|
second = 0
|
||||||
|
): { date: Date; clamped: boolean } | null {
|
||||||
|
const days = daysInMonth(d.year, d.month);
|
||||||
|
if (days === 0) return null;
|
||||||
|
let day = d.day;
|
||||||
|
let clamped = false;
|
||||||
|
if (day > days) {
|
||||||
|
day = days;
|
||||||
|
clamped = true;
|
||||||
|
}
|
||||||
|
if (day < 1) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const s = Lunar.fromYmd(d.year, d.month, day).getSolar();
|
||||||
|
return {
|
||||||
|
date: new Date(s.getYear(), s.getMonth() - 1, s.getDay(), hour, minute, second, 0),
|
||||||
|
clamped
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在农历上推进若干个月。
|
||||||
|
*
|
||||||
|
* 逐月走而不是「月份数 + n 取模」:中间可能夹着闰月,而闰月是否存在
|
||||||
|
* 取决于年份,没有闭式公式。
|
||||||
|
*
|
||||||
|
* **推进时跳过闰月**:从六月推一个月得七月,不是闰六月。「每月十五」
|
||||||
|
* 这类规则的用户期望是一年 12 次,把闰月算进去会让闰年多出一次提醒 ——
|
||||||
|
* 那是农历年的性质,不是提醒的性质。
|
||||||
|
*/
|
||||||
|
export function addLunarMonths(d: LunarDate, n: number): LunarDate {
|
||||||
|
let { year, month } = d;
|
||||||
|
// 从闰月出发时先归到对应的正月份:闰六月 +1 → 七月
|
||||||
|
if (month < 0) month = -month;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
month++;
|
||||||
|
if (month > 12) {
|
||||||
|
month = 1;
|
||||||
|
year++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { year, month, day: d.day };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在农历上推进若干年,月份与日期保持不变。
|
||||||
|
*
|
||||||
|
* 从闰月出发而目标年没有同一个闰月时,退回对应的正月份 ——
|
||||||
|
* 「去年闰六月十五」在今年最接近的对应日就是六月十五。
|
||||||
|
* 直接放弃(不再提醒)更糟:那是静默地让重复事件消失。
|
||||||
|
*/
|
||||||
|
export function addLunarYears(d: LunarDate, n: number): LunarDate {
|
||||||
|
const year = d.year + n;
|
||||||
|
let month = d.month;
|
||||||
|
if (month < 0 && leapMonth(year) !== -month) {
|
||||||
|
month = -month;
|
||||||
|
}
|
||||||
|
return { year, month, day: d.day };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 「二〇二六年七月廿二」这样的完整中文农历表示。 */
|
||||||
|
export function formatLunarFull(d: LunarDate): string {
|
||||||
|
const days = daysInMonth(d.year, d.month);
|
||||||
|
if (days === 0) return `农历 ${d.year}-${d.month}-${d.day}(无效)`;
|
||||||
|
const day = Math.min(d.day, days);
|
||||||
|
try {
|
||||||
|
return Lunar.fromYmd(d.year, d.month, day).toString();
|
||||||
|
} catch {
|
||||||
|
return `农历 ${d.year}-${d.month}-${d.day}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 只要月日的简短农历,如「七月廿二」。
|
||||||
|
*
|
||||||
|
* 日历格子里用这个:年份已经在页头写了,每格重复一遍挤不下也没意义。
|
||||||
|
*/
|
||||||
|
export function formatLunarShort(d: LunarDate): string {
|
||||||
|
const full = formatLunarFull(d);
|
||||||
|
// 「二〇二六年」= 4 个数字字 + 「年」
|
||||||
|
const chars = Array.from(full);
|
||||||
|
if (chars.length > 5 && chars[4] === '年') {
|
||||||
|
return chars.slice(5).join('');
|
||||||
|
}
|
||||||
|
return full;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 农历日名,如 1 → 初一、20 → 二十、22 → 廿二、30 → 三十。
|
||||||
|
*
|
||||||
|
* 用查表而不是从 `Lunar.toString()` 里正则截取:实测日名有五种前缀形态
|
||||||
|
* (初一/十一/二十/廿一/三十),写一个覆盖全部的正则既难读又容易漏 ——
|
||||||
|
* 之前那版就漏了「二十」,20 号会退化成显示整串「七月二十」。
|
||||||
|
*/
|
||||||
|
const LUNAR_DAY_NAMES = [
|
||||||
|
'', '初一', '初二', '初三', '初四', '初五', '初六', '初七', '初八', '初九', '初十',
|
||||||
|
'十一', '十二', '十三', '十四', '十五', '十六', '十七', '十八', '十九', '二十',
|
||||||
|
'廿一', '廿二', '廿三', '廿四', '廿五', '廿六', '廿七', '廿八', '廿九', '三十'
|
||||||
|
];
|
||||||
|
|
||||||
|
export function lunarDayName(day: number): string {
|
||||||
|
return LUNAR_DAY_NAMES[day] ?? String(day);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 日历格子里显示的农历标记:初一显示月名,其余显示日名。
|
||||||
|
*
|
||||||
|
* 每格都写完整「七月廿二」会让格子里全是重复的月份字样,而格子只有
|
||||||
|
* 几十像素宽。月初那天写月名(如「七月」)就够定位了 —— 纸质日历的惯例。
|
||||||
|
*/
|
||||||
|
export function cellLunarLabel(date: Date): string {
|
||||||
|
const d = fromSolar(date);
|
||||||
|
if (d.day === 1) {
|
||||||
|
// 初一:写月名。闰月要带「闰」字,否则闰六月与六月在格子里长得一样
|
||||||
|
const leap = d.month < 0 ? '闰' : '';
|
||||||
|
return `${leap}${LUNAR_MONTH_NAMES[Math.abs(d.month)] ?? Math.abs(d.month)}月`;
|
||||||
|
}
|
||||||
|
return lunarDayName(d.day);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 农历月名。十一/十二月习惯写「冬月」「腊月」,与 lunar-javascript 一致。 */
|
||||||
|
const LUNAR_MONTH_NAMES = [
|
||||||
|
'', '正', '二', '三', '四', '五', '六', '七', '八', '九', '十', '冬', '腊'
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 公历 Date → 「2026-09-03(农历七月廿二)」。给提醒预览与详情用。 */
|
||||||
|
export function formatSolarWithLunar(d: Date): string {
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
const ymd = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||||
|
return `${ymd}(农历${formatLunarShort(fromSolar(d))})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否是按农历推进的规则。 */
|
||||||
|
export function isLunarRecurrence(r: string): boolean {
|
||||||
|
return r === 'lunar_monthly' || r === 'lunar_yearly';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 算出下一次触发时刻。必须与后端 `repo.NextOccurrence` 行为一致 ——
|
||||||
|
* 前端用它预览「接下来几次在哪天」,与实际触发不符比不预览更糟。
|
||||||
|
*
|
||||||
|
* 返回 null 表示不重复或算不出来。
|
||||||
|
*/
|
||||||
|
export function nextOccurrence(recurrence: string, from: Date): Date | null {
|
||||||
|
switch (recurrence) {
|
||||||
|
case 'daily':
|
||||||
|
return shiftDays(from, 1);
|
||||||
|
case 'weekly':
|
||||||
|
return shiftDays(from, 7);
|
||||||
|
case 'monthly':
|
||||||
|
return addSolarMonthsClamped(from, 1);
|
||||||
|
case 'yearly':
|
||||||
|
return addSolarMonthsClamped(from, 12);
|
||||||
|
case 'lunar_monthly': {
|
||||||
|
const r = toSolar(
|
||||||
|
addLunarMonths(fromSolar(from), 1),
|
||||||
|
from.getHours(),
|
||||||
|
from.getMinutes(),
|
||||||
|
from.getSeconds()
|
||||||
|
);
|
||||||
|
return r ? r.date : null;
|
||||||
|
}
|
||||||
|
case 'lunar_yearly': {
|
||||||
|
const r = toSolar(
|
||||||
|
addLunarYears(fromSolar(from), 1),
|
||||||
|
from.getHours(),
|
||||||
|
from.getMinutes(),
|
||||||
|
from.getSeconds()
|
||||||
|
);
|
||||||
|
return r ? r.date : null;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shiftDays(d: Date, n: number): Date {
|
||||||
|
const x = new Date(d);
|
||||||
|
x.setDate(x.getDate() + n);
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 公历加月份,日期夹到目标月的实际天数内。
|
||||||
|
*
|
||||||
|
* `setMonth` 的溢出行为(3 月 31 日 +1 月 = 5 月 1 日)对「每月同一日」
|
||||||
|
* 是错的:31 日的事件在 2 月会变成 3 月 3 日,然后从此每月 3 日提醒 ——
|
||||||
|
* 一次溢出永久改变了规则。
|
||||||
|
*/
|
||||||
|
export function addSolarMonthsClamped(d: Date, n: number): Date {
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = d.getMonth() + n;
|
||||||
|
// 目标月第 0 天 = 上个月最后一天,用它拿月长
|
||||||
|
const last = new Date(y, m + 1, 0).getDate();
|
||||||
|
const day = Math.min(d.getDate(), last);
|
||||||
|
return new Date(y, m, day, d.getHours(), d.getMinutes(), d.getSeconds(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预览接下来 n 次触发。事件编辑器里用它让人确认规则没被理解错 ——
|
||||||
|
* 农历规则的公历日期每次都在变,光看规则名分辨不出对不对。
|
||||||
|
*/
|
||||||
|
export function upcomingOccurrences(recurrence: string, from: Date, n = 3): Date[] {
|
||||||
|
const out: Date[] = [];
|
||||||
|
let cur = from;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const next = nextOccurrence(recurrence, cur);
|
||||||
|
// 算不出来(例如目标年没有那个闰月)就停在这里,
|
||||||
|
// 而不是跳过继续试 —— 后端的 AdvanceRecurrence 也会在这一步放弃。
|
||||||
|
if (!next || next <= cur) break;
|
||||||
|
out.push(next);
|
||||||
|
cur = next;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重复规则的人类可读标签。含农历时把农历日子写出来。 */
|
||||||
|
export function describeRecurrenceRule(recurrence: string, eventTime?: Date): string {
|
||||||
|
switch (recurrence) {
|
||||||
|
case 'none':
|
||||||
|
return '不重复';
|
||||||
|
case 'daily':
|
||||||
|
return '每天';
|
||||||
|
case 'weekly':
|
||||||
|
return '每周';
|
||||||
|
case 'monthly':
|
||||||
|
return '每月';
|
||||||
|
case 'yearly':
|
||||||
|
return '每年';
|
||||||
|
case 'lunar_monthly':
|
||||||
|
return eventTime
|
||||||
|
? `每农历月${dayNameOf(eventTime)}`
|
||||||
|
: '每农历月同一日';
|
||||||
|
case 'lunar_yearly':
|
||||||
|
return eventTime
|
||||||
|
? `每年农历${formatLunarShort(fromSolar(eventTime))}`
|
||||||
|
: '每农历年同月同日';
|
||||||
|
default:
|
||||||
|
return '不重复';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取「廿二」这样的农历日名。 */
|
||||||
|
function dayNameOf(d: Date): string {
|
||||||
|
return lunarDayName(fromSolar(d).day);
|
||||||
|
}
|
||||||
57
web/src/types/lunar-javascript.d.ts
vendored
Normal file
57
web/src/types/lunar-javascript.d.ts
vendored
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* lunar-javascript 的类型声明。
|
||||||
|
*
|
||||||
|
* 上游没有发布 .d.ts(也没有 @types/lunar-javascript),不声明的话
|
||||||
|
* `import` 直接 TS7016 编译失败。
|
||||||
|
*
|
||||||
|
* 只声明我们真正用到的成员而不是 `declare module 'lunar-javascript'`
|
||||||
|
* (那等于放弃整个模块的类型)—— 写错方法名时仍要能在编译期发现,
|
||||||
|
* 否则会变成运行时的「undefined is not a function」,而日历页面
|
||||||
|
* 一旦抛错整片区域白屏。
|
||||||
|
*/
|
||||||
|
declare module 'lunar-javascript' {
|
||||||
|
export interface LunarMonthLike {
|
||||||
|
getYear(): number;
|
||||||
|
/** 负数表示闰月 */
|
||||||
|
getMonth(): number;
|
||||||
|
/** 29 或 30 */
|
||||||
|
getDayCount(): number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SolarLike {
|
||||||
|
getYear(): number;
|
||||||
|
/** 1-12 */
|
||||||
|
getMonth(): number;
|
||||||
|
getDay(): number;
|
||||||
|
toYmd(): string;
|
||||||
|
toString(): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LunarLike {
|
||||||
|
getYear(): number;
|
||||||
|
/** 负数表示闰月 */
|
||||||
|
getMonth(): number;
|
||||||
|
getDay(): number;
|
||||||
|
getSolar(): SolarLike;
|
||||||
|
/** 「二〇二六年七月廿二」 */
|
||||||
|
toString(): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Solar: {
|
||||||
|
fromYmd(year: number, month: number, day: number): SolarLike & { getLunar(): LunarLike };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Lunar: {
|
||||||
|
/** month 传负数表示闰月。**非法日期会 throw** —— 农历月是 29 或 30 天不定。 */
|
||||||
|
fromYmd(year: number, month: number, day: number): LunarLike;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LunarYear: {
|
||||||
|
fromYear(year: number): {
|
||||||
|
/** 0 = 无闰月 */
|
||||||
|
getLeapMonth(): number;
|
||||||
|
/** 含跨年边界的月份,因此使用时必须同时比对 getYear() */
|
||||||
|
getMonths(): LunarMonthLike[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
268
web/test/components/lunar.test.tsx
Normal file
268
web/test/components/lunar.test.tsx
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
fromSolar, toSolar, leapMonth, daysInMonth,
|
||||||
|
addLunarMonths, addLunarYears,
|
||||||
|
formatLunarFull, formatLunarShort, cellLunarLabel, lunarDayName,
|
||||||
|
formatSolarWithLunar, isLunarRecurrence,
|
||||||
|
nextOccurrence, addSolarMonthsClamped, upcomingOccurrences,
|
||||||
|
describeRecurrenceRule
|
||||||
|
} from '../../src/lib/lunar';
|
||||||
|
|
||||||
|
describe('公历 ↔ 农历', () => {
|
||||||
|
it('已知锚点', () => {
|
||||||
|
const d = fromSolar(new Date(2026, 8, 3));
|
||||||
|
expect(d).toEqual({ year: 2026, month: 7, day: 22 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('闰月用负数月份表示', () => {
|
||||||
|
// 2025 有闰六月
|
||||||
|
expect(leapMonth(2025)).toBe(6);
|
||||||
|
const d = fromSolar(new Date(2025, 6, 25)); // 2025-07-25
|
||||||
|
expect(d.month).toBe(-6);
|
||||||
|
expect(d.day).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('无闰月的年份返回 0', () => {
|
||||||
|
expect(leapMonth(2026)).toBe(0);
|
||||||
|
expect(leapMonth(2027)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('往返不丢日期', () => {
|
||||||
|
for (const [y, m, dd] of [[2026, 8, 3], [2027, 1, 14], [2025, 6, 25]] as const) {
|
||||||
|
const solar = new Date(y, m, dd);
|
||||||
|
const lunar = fromSolar(solar);
|
||||||
|
const back = toSolar(lunar, 9, 30);
|
||||||
|
expect(back).not.toBeNull();
|
||||||
|
expect(back!.clamped).toBe(false);
|
||||||
|
expect(back!.date.getFullYear()).toBe(y);
|
||||||
|
expect(back!.date.getMonth()).toBe(m);
|
||||||
|
expect(back!.date.getDate()).toBe(dd);
|
||||||
|
// 时钟原样带过去(农历只定义到「日」)
|
||||||
|
expect(back!.date.getHours()).toBe(9);
|
||||||
|
expect(back!.date.getMinutes()).toBe(30);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('短月份夹取', () => {
|
||||||
|
it('2027 农历九月只有 29 天', () => {
|
||||||
|
expect(daysInMonth(2027, 9)).toBe(29);
|
||||||
|
expect(daysInMonth(2026, 1)).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 库在 Lunar.fromYmd(2027,9,30) 上直接 throw。
|
||||||
|
// 「每月农历三十」必然撞上 29 天的月份,不夹住就是整片白屏。
|
||||||
|
it('要三十而该月只有廿九时夹到廿九,不抛错', () => {
|
||||||
|
const r = toSolar({ year: 2027, month: 9, day: 30 }, 9, 0);
|
||||||
|
expect(r).not.toBeNull();
|
||||||
|
expect(r!.clamped).toBe(true);
|
||||||
|
// 夹取后必须仍在同一个农历月内(滚到下月初一是错的)
|
||||||
|
expect(fromSolar(r!.date).month).toBe(9);
|
||||||
|
expect(fromSolar(r!.date).day).toBe(29);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('不存在的闰月返回 null 而不是猜一个日子', () => {
|
||||||
|
expect(daysInMonth(2026, -6)).toBe(0);
|
||||||
|
expect(toSolar({ year: 2026, month: -6, day: 1 })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('非法日返回 null', () => {
|
||||||
|
expect(toSolar({ year: 2026, month: 1, day: 0 })).toBeNull();
|
||||||
|
expect(toSolar({ year: 2026, month: 13, day: 1 })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('农历推进', () => {
|
||||||
|
it('加月跨年', () => {
|
||||||
|
expect(addLunarMonths({ year: 2026, month: 12, day: 5 }, 1)).toEqual({ year: 2027, month: 1, day: 5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('推 12 次回到次年同月', () => {
|
||||||
|
expect(addLunarMonths({ year: 2026, month: 7, day: 22 }, 12))
|
||||||
|
.toEqual({ year: 2027, month: 7, day: 22 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 「每月十五」的期望是一年 12 次,把闰月算进去会让闰年多一次
|
||||||
|
it('从闰月出发先归正月份', () => {
|
||||||
|
expect(addLunarMonths({ year: 2025, month: -6, day: 15 }, 1).month).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('加年保持月日', () => {
|
||||||
|
expect(addLunarYears({ year: 2026, month: 7, day: 22 }, 1))
|
||||||
|
.toEqual({ year: 2027, month: 7, day: 22 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 静默让重复事件消失比退回正月份更糟
|
||||||
|
it('目标年无同一闰月时退回正月份', () => {
|
||||||
|
expect(addLunarYears({ year: 2025, month: -6, day: 15 }, 1))
|
||||||
|
.toEqual({ year: 2026, month: 6, day: 15 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('中文表示', () => {
|
||||||
|
it('完整表示', () => {
|
||||||
|
expect(formatLunarFull({ year: 2026, month: 7, day: 22 })).toBe('二〇二六年七月廿二');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('闰月带「闰」字', () => {
|
||||||
|
expect(formatLunarFull({ year: 2025, month: -6, day: 1 })).toBe('二〇二五年闰六月初一');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('短表示去掉年份', () => {
|
||||||
|
expect(formatLunarShort({ year: 2026, month: 7, day: 22 })).toBe('七月廿二');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 日名有五种前缀形态(初一/十一/二十/廿一/三十),
|
||||||
|
// 用正则截取时「二十」曾被漏掉
|
||||||
|
it('日名查表覆盖全部 30 天', () => {
|
||||||
|
const names = Array.from({ length: 30 }, (_, i) => lunarDayName(i + 1));
|
||||||
|
expect(names[0]).toBe('初一');
|
||||||
|
expect(names[9]).toBe('初十');
|
||||||
|
expect(names[10]).toBe('十一');
|
||||||
|
expect(names[19]).toBe('二十');
|
||||||
|
expect(names[20]).toBe('廿一');
|
||||||
|
expect(names[29]).toBe('三十');
|
||||||
|
expect(new Set(names).size).toBe(30); // 无重复
|
||||||
|
expect(names.every(n => n.length === 2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('格子标记:初一显示月名,其余显示日名', () => {
|
||||||
|
// 2026-08-13 是农历七月初一
|
||||||
|
const firstDay = toSolar({ year: 2026, month: 7, day: 1 })!.date;
|
||||||
|
expect(cellLunarLabel(firstDay)).toBe('七月');
|
||||||
|
expect(cellLunarLabel(new Date(2026, 8, 3))).toBe('廿二');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('闰月初一的格子标记带「闰」', () => {
|
||||||
|
const leapFirst = toSolar({ year: 2025, month: -6, day: 1 })!.date;
|
||||||
|
expect(cellLunarLabel(leapFirst)).toBe('闰六月');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('公历+农历合并显示', () => {
|
||||||
|
expect(formatSolarWithLunar(new Date(2026, 8, 3))).toBe('2026-09-03(农历七月廿二)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('nextOccurrence 与后端 repo.NextOccurrence 对齐', () => {
|
||||||
|
const base = new Date(2026, 8, 3, 9, 30);
|
||||||
|
|
||||||
|
it('公历三种', () => {
|
||||||
|
expect(nextOccurrence('daily', base)!.getDate()).toBe(4);
|
||||||
|
expect(nextOccurrence('weekly', base)!.getDate()).toBe(10);
|
||||||
|
expect(nextOccurrence('monthly', base)!.getMonth()).toBe(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('公历每年', () => {
|
||||||
|
const n = nextOccurrence('yearly', base)!;
|
||||||
|
expect(n.getFullYear()).toBe(2027);
|
||||||
|
expect(n.getMonth()).toBe(8);
|
||||||
|
expect(n.getDate()).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('none 与未知值给 null', () => {
|
||||||
|
expect(nextOccurrence('none', base)).toBeNull();
|
||||||
|
expect(nextOccurrence('每隔一个蓝月亮', base)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('时钟保留', () => {
|
||||||
|
for (const r of ['daily', 'weekly', 'monthly', 'yearly', 'lunar_monthly', 'lunar_yearly']) {
|
||||||
|
const n = nextOccurrence(r, base);
|
||||||
|
expect(n).not.toBeNull();
|
||||||
|
expect(n!.getHours()).toBe(9);
|
||||||
|
expect(n!.getMinutes()).toBe(30);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// setMonth 的溢出(3月31日 +1月 = 5月1日)会永久改变规则:
|
||||||
|
// 31 日的事件在 2 月变成 3 月 3 日,然后从此每月 3 日提醒
|
||||||
|
describe('公历月末夹取', () => {
|
||||||
|
it.each([
|
||||||
|
['2026-01-31', 1, '2026-02-28'],
|
||||||
|
['2026-03-31', 1, '2026-04-30'],
|
||||||
|
['2028-01-31', 1, '2028-02-29'],
|
||||||
|
['2028-02-29', 12, '2029-02-28'],
|
||||||
|
['2026-01-15', 1, '2026-02-15']
|
||||||
|
])('%s + %i 月 → %s', (from, n, want) => {
|
||||||
|
const [y, m, d] = from.split('-').map(Number);
|
||||||
|
const got = addSolarMonthsClamped(new Date(y, m - 1, d), n);
|
||||||
|
const pad = (x: number) => String(x).padStart(2, '0');
|
||||||
|
expect(`${got.getFullYear()}-${pad(got.getMonth() + 1)}-${pad(got.getDate())}`).toBe(want);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('农历重复的公历漂移', () => {
|
||||||
|
it('农历月间隔在 29~30 天之间浮动,不是固定值', () => {
|
||||||
|
let cur = new Date(2026, 8, 3, 9, 0);
|
||||||
|
const gaps = new Set<number>();
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const next = nextOccurrence('lunar_monthly', cur)!;
|
||||||
|
expect(next.getTime()).toBeGreaterThan(cur.getTime());
|
||||||
|
gaps.add(Math.round((next.getTime() - cur.getTime()) / 86400000));
|
||||||
|
// 农历「日」保持不变
|
||||||
|
expect(fromSolar(next).day).toBe(22);
|
||||||
|
cur = next;
|
||||||
|
}
|
||||||
|
expect(gaps.size).toBeGreaterThan(1);
|
||||||
|
for (const g of gaps) expect(g).toBeGreaterThanOrEqual(28);
|
||||||
|
for (const g of gaps) expect(g).toBeLessThanOrEqual(31);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 这是农历规则存在的理由:用公历 yearly 日子会固定,
|
||||||
|
// 与「过农历生日/祭日」的期望不符
|
||||||
|
it('农历年推进时公历月日每年都变', () => {
|
||||||
|
let cur = new Date(2026, 8, 3, 9, 0);
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const next = nextOccurrence('lunar_yearly', cur)!;
|
||||||
|
const d = fromSolar(next);
|
||||||
|
expect(d.month).toBe(7);
|
||||||
|
expect(d.day).toBe(22);
|
||||||
|
seen.add(`${next.getMonth() + 1}-${next.getDate()}`);
|
||||||
|
cur = next;
|
||||||
|
}
|
||||||
|
expect(seen.size).toBeGreaterThanOrEqual(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('upcomingOccurrences', () => {
|
||||||
|
it('给出连续递增的 n 次', () => {
|
||||||
|
const list = upcomingOccurrences('lunar_monthly', new Date(2026, 8, 3, 9, 0), 3);
|
||||||
|
expect(list).toHaveLength(3);
|
||||||
|
for (let i = 1; i < list.length; i++) {
|
||||||
|
expect(list[i].getTime()).toBeGreaterThan(list[i - 1].getTime());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('不重复时给空数组', () => {
|
||||||
|
expect(upcomingOccurrences('none', new Date(), 3)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('describeRecurrenceRule', () => {
|
||||||
|
it('公历规则', () => {
|
||||||
|
expect(describeRecurrenceRule('none')).toBe('不重复');
|
||||||
|
expect(describeRecurrenceRule('daily')).toBe('每天');
|
||||||
|
expect(describeRecurrenceRule('weekly')).toBe('每周');
|
||||||
|
expect(describeRecurrenceRule('monthly')).toBe('每月');
|
||||||
|
expect(describeRecurrenceRule('yearly')).toBe('每年');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('农历规则把农历日子写出来', () => {
|
||||||
|
const t = new Date(2026, 8, 3);
|
||||||
|
expect(describeRecurrenceRule('lunar_monthly', t)).toBe('每农历月廿二');
|
||||||
|
expect(describeRecurrenceRule('lunar_yearly', t)).toBe('每年农历七月廿二');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('无事件时间时退回泛化描述', () => {
|
||||||
|
expect(describeRecurrenceRule('lunar_monthly')).toBe('每农历月同一日');
|
||||||
|
expect(describeRecurrenceRule('lunar_yearly')).toBe('每农历年同月同日');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isLunarRecurrence', () => {
|
||||||
|
expect(isLunarRecurrence('lunar_monthly')).toBe(true);
|
||||||
|
expect(isLunarRecurrence('lunar_yearly')).toBe(true);
|
||||||
|
expect(isLunarRecurrence('monthly')).toBe(false);
|
||||||
|
expect(isLunarRecurrence('yearly')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user