diff --git a/gateway/go.mod b/gateway/go.mod index b971e2c..d777c68 100644 --- a/gateway/go.mod +++ b/gateway/go.mod @@ -12,6 +12,7 @@ require ( ) require ( + github.com/6tail/lunar-go v1.4.6 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect diff --git a/gateway/go.sum b/gateway/go.sum index da5df95..e47f30b 100644 --- a/gateway/go.sum +++ b/gateway/go.sum @@ -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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/gateway/internal/lunar/lunar.go b/gateway/internal/lunar/lunar.go new file mode 100644 index 0000000..37d7d91 --- /dev/null +++ b/gateway/internal/lunar/lunar.go @@ -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) +} diff --git a/gateway/internal/lunar/lunar_test.go b/gateway/internal/lunar/lunar_test.go new file mode 100644 index 0000000..0ab113e --- /dev/null +++ b/gateway/internal/lunar/lunar_test.go @@ -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()) + } +} diff --git a/web/package-lock.json b/web/package-lock.json index 8d2327a..9bc4c30 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,6 +8,7 @@ "name": "agentmail-web", "version": "0.1.0", "dependencies": { + "lunar-javascript": "1.7.7", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^9.0.1", @@ -2957,6 +2958,12 @@ "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": { "version": "1.5.0", "resolved": "https://registry.npmmirror.com/lz-string/-/lz-string-1.5.0.tgz", diff --git a/web/package.json b/web/package.json index 514973e..a6f2f7a 100644 --- a/web/package.json +++ b/web/package.json @@ -8,13 +8,14 @@ "build": "vite build", "preview": "vite preview", "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:wide": "node test/manual/wide-regression.mjs", "test:components": "vitest run", "test:watch": "vitest" }, "dependencies": { + "lunar-javascript": "1.7.7", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^9.0.1", diff --git a/web/src/lib/lunar.ts b/web/src/lib/lunar.ts new file mode 100644 index 0000000..b429e02 --- /dev/null +++ b/web/src/lib/lunar.ts @@ -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); +} diff --git a/web/src/types/lunar-javascript.d.ts b/web/src/types/lunar-javascript.d.ts new file mode 100644 index 0000000..73dab56 --- /dev/null +++ b/web/src/types/lunar-javascript.d.ts @@ -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[]; + }; + }; +} diff --git a/web/test/components/lunar.test.tsx b/web/test/components/lunar.test.tsx new file mode 100644 index 0000000..14facea --- /dev/null +++ b/web/test/components/lunar.test.tsx @@ -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(); + 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(); + 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); + }); +});