feat(web): 日历前端 —— 月/周/日三粒度 + 事件编辑器 + 农历显示
布局与全站一致:**内容区自己再分两栏**。 第一版是单栏 —— 网格铺满整个主区域,宽屏下右边一大片空白无事可做, 而点「新建」时编辑器**顶掉**整个日历,人失去正在看的那个月的上下文。 两个问题同源:日历没有「详情栏」这个位置。 现在左边网格、右边常驻面板:默认显示选中那天的日程(所以永远不空), 新建/编辑时同一位置变编辑器 —— 与「新建邮件是右侧整页」同一套语言。 窄屏退回覆盖式(NarrowStack,与收件箱详情同一组件同一段动画)。 lib/calendar.ts 里三个易错点(都有测试钉住): **周首必须是周一。** getDay() 把周日算作 0,直接减它会让周日归到上一周 末尾,月视图第一行整体错位。 **分桶用本地日期串而不是 toISOString().slice(0,10)。** 后者给 UTC 日期, 东八区晚上 8 点后的事件会被归到第二天的格子里。 **月视图固定 42 格。** 按需 4~6 行会让网格高度随月份跳动,翻月时页面弹动。 另有一个 TS 陷阱:replaceAll 在 tsconfig 的 target: ES2020 下不存在 (TS2550)。改用 split/join —— **不能**退回 replace,那只换第一个, 同一变量写两次时第二个会原样漏进邮件。 编辑器: - 农历规则预览**接下来三次的公历日期**。规则名(「每农历月廿二」)看不出 公历日子,而那日子每次都在变 —— 不预览的话人要等一个月才知道理解对没对。 - 变量按钮 + 实时渲染预览。「模板里写了什么」和「Agent 收到什么」不是一个 东西,不给预览人只能发一次试试看。 - 收件人列表可增删调序(together 模式下首个是主收件人,顺序有语义)。 编辑旧事件时初值走与后端相同的兜底链 —— 不做归一化的话,编辑一条老事件 再保存会把收件人清空(列表是空的,保存就覆盖了)。 - remindAt 单独显示:调度器比较的是「事件时间 − 提前分钟」那个点, 不显示的话人设了「提前 30 分钟」却在事件时间才反应过来。 日历格子标农历日(初一显示月名,纸质日历惯例)。暂停/取消的事件加删除线 与图标 —— 否则人以为设好了,实际到点什么都不会发生。 exportCalendarICS 不走 request()(那个假定 JSON,这里是 text/calendar), 返回文本让调用方用 Blob 触发下载而不是让浏览器导航 —— 导航会丢掉 cookie 之外的认证头。 测试 35 例。
This commit is contained in:
281
web/test/components/calendar.test.tsx
Normal file
281
web/test/components/calendar.test.tsx
Normal file
@ -0,0 +1,281 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
startOfDay, endOfDay, startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
||||
isSameDay, addDays, addMonths, monthGrid, weekDays, remindAt,
|
||||
bucketByDay, dayKey, renderReminder, toLocalInput, fromLocalInput,
|
||||
describeRecurrence, describeRemindBefore
|
||||
} from '../../src/lib/calendar';
|
||||
import type { CalendarEvent } from '../../src/types';
|
||||
|
||||
let seq = 0;
|
||||
function ev(over: Partial<CalendarEvent> = {}): CalendarEvent {
|
||||
seq += 1;
|
||||
return {
|
||||
event_id: `e${String(seq).padStart(3, '0')}`,
|
||||
title: `事件 ${seq}`,
|
||||
description: '',
|
||||
reminder_text: '',
|
||||
agent_name: 'pi',
|
||||
to_address: '',
|
||||
recipients: [],
|
||||
delivery_mode: 'separate',
|
||||
event_time: '2026-09-03T14:30:00+08:00',
|
||||
remind_before: 0,
|
||||
recurrence: 'none',
|
||||
status: 'active',
|
||||
created_at: '2026-09-01T00:00:00Z',
|
||||
updated_at: '2026-09-01T00:00:00Z',
|
||||
created_by: 'jianf',
|
||||
...over
|
||||
};
|
||||
}
|
||||
|
||||
describe('日期边界', () => {
|
||||
it('startOfDay / endOfDay 用本地时区', () => {
|
||||
const d = new Date(2026, 8, 3, 14, 30, 45, 123);
|
||||
expect(startOfDay(d).getHours()).toBe(0);
|
||||
expect(startOfDay(d).getMilliseconds()).toBe(0);
|
||||
expect(endOfDay(d).getHours()).toBe(23);
|
||||
expect(endOfDay(d).getMilliseconds()).toBe(999);
|
||||
// 不修改入参
|
||||
expect(d.getHours()).toBe(14);
|
||||
});
|
||||
|
||||
it('startOfMonth / endOfMonth', () => {
|
||||
const d = new Date(2026, 8, 15);
|
||||
expect(startOfMonth(d).getDate()).toBe(1);
|
||||
expect(endOfMonth(d).getDate()).toBe(30); // 9 月 30 天
|
||||
expect(endOfMonth(new Date(2026, 1, 5)).getDate()).toBe(28); // 2026 年 2 月
|
||||
});
|
||||
|
||||
it('周从周一开始,周日归到上一周末尾', () => {
|
||||
// 2026-09-06 是周日
|
||||
const sunday = new Date(2026, 8, 6);
|
||||
expect(sunday.getDay()).toBe(0);
|
||||
// 它所在周的周一应是 08-31,不是 09-07
|
||||
expect(dayKey(startOfWeek(sunday))).toBe('2026-08-31');
|
||||
expect(dayKey(endOfWeek(sunday))).toBe('2026-09-06');
|
||||
});
|
||||
|
||||
it('周一自己就是周首', () => {
|
||||
const monday = new Date(2026, 8, 7);
|
||||
expect(monday.getDay()).toBe(1);
|
||||
expect(dayKey(startOfWeek(monday))).toBe('2026-09-07');
|
||||
});
|
||||
});
|
||||
|
||||
describe('日期运算', () => {
|
||||
it('addDays 不修改入参', () => {
|
||||
const d = new Date(2026, 8, 3);
|
||||
const r = addDays(d, 5);
|
||||
expect(dayKey(r)).toBe('2026-09-08');
|
||||
expect(dayKey(d)).toBe('2026-09-03');
|
||||
});
|
||||
|
||||
it('addDays 跨月', () => {
|
||||
expect(dayKey(addDays(new Date(2026, 8, 29), 5))).toBe('2026-10-04');
|
||||
});
|
||||
|
||||
it('addMonths 从月末加一个月不会溢出', () => {
|
||||
// setMonth 在 1 月 31 日上加一个月会给 3 月 2/3 日
|
||||
const jan31 = new Date(2026, 0, 31);
|
||||
expect(dayKey(addMonths(jan31, 1))).toBe('2026-02-01');
|
||||
});
|
||||
|
||||
it('isSameDay 只比年月日', () => {
|
||||
expect(isSameDay(new Date(2026, 8, 3, 0, 0), new Date(2026, 8, 3, 23, 59))).toBe(true);
|
||||
expect(isSameDay(new Date(2026, 8, 3), new Date(2026, 8, 4))).toBe(false);
|
||||
expect(isSameDay(new Date(2026, 8, 3), new Date(2025, 8, 3))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('monthGrid', () => {
|
||||
it('固定 42 格(6 行 × 7 列)', () => {
|
||||
// 行数变化会让网格高度跳动,翻月时页面内容上下弹
|
||||
expect(monthGrid(new Date(2026, 8, 15))).toHaveLength(42);
|
||||
expect(monthGrid(new Date(2026, 1, 15))).toHaveLength(42);
|
||||
});
|
||||
|
||||
it('首格是月首所在周的周一', () => {
|
||||
// 2026-09-01 是周二 → 首格应是 08-31(周一)
|
||||
const cells = monthGrid(new Date(2026, 8, 15));
|
||||
expect(dayKey(cells[0])).toBe('2026-08-31');
|
||||
});
|
||||
|
||||
it('格子连续无空洞', () => {
|
||||
const cells = monthGrid(new Date(2026, 8, 15));
|
||||
for (let i = 1; i < cells.length; i++) {
|
||||
const diff = cells[i].getTime() - cells[i - 1].getTime();
|
||||
// 允许夏令时造成的 ±1 小时偏差
|
||||
expect(diff).toBeGreaterThanOrEqual(23 * 3600_000);
|
||||
expect(diff).toBeLessThanOrEqual(25 * 3600_000);
|
||||
}
|
||||
});
|
||||
|
||||
it('包含整个当月', () => {
|
||||
const anchor = new Date(2026, 8, 15);
|
||||
const keys = monthGrid(anchor).map(dayKey);
|
||||
expect(keys).toContain('2026-09-01');
|
||||
expect(keys).toContain('2026-09-30');
|
||||
});
|
||||
});
|
||||
|
||||
describe('weekDays', () => {
|
||||
it('给 7 天,周一起头', () => {
|
||||
const days = weekDays(new Date(2026, 8, 3)); // 周四
|
||||
expect(days).toHaveLength(7);
|
||||
expect(dayKey(days[0])).toBe('2026-08-31');
|
||||
expect(dayKey(days[6])).toBe('2026-09-06');
|
||||
});
|
||||
});
|
||||
|
||||
describe('remindAt', () => {
|
||||
it('提前 30 分钟', () => {
|
||||
const e = ev({ event_time: '2026-09-03T14:30:00+08:00', remind_before: 30 });
|
||||
expect(remindAt(e).toISOString()).toBe('2026-09-03T06:00:00.000Z');
|
||||
});
|
||||
|
||||
it('remind_before 为 0 时就是事件时间', () => {
|
||||
const e = ev({ event_time: '2026-09-03T14:30:00+08:00', remind_before: 0 });
|
||||
expect(remindAt(e).toISOString()).toBe('2026-09-03T06:30:00.000Z');
|
||||
});
|
||||
|
||||
it('提前一整天', () => {
|
||||
const e = ev({ event_time: '2026-09-03T14:30:00+08:00', remind_before: 1440 });
|
||||
expect(remindAt(e).toISOString()).toBe('2026-09-02T06:30:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bucketByDay', () => {
|
||||
it('按本地日期分桶,不用 UTC 前缀', () => {
|
||||
// 东八区 22:00 → UTC 是前一天 14:00。用 toISOString().slice(0,10) 会归错天
|
||||
const e = ev({ event_time: '2026-09-03T22:00:00+08:00' });
|
||||
const m = bucketByDay([e]);
|
||||
expect([...m.keys()]).toEqual(['2026-09-03']);
|
||||
});
|
||||
|
||||
it('同一天内按时间正序', () => {
|
||||
const late = ev({ event_time: '2026-09-03T18:00:00+08:00', title: '晚' });
|
||||
const early = ev({ event_time: '2026-09-03T09:00:00+08:00', title: '早' });
|
||||
const m = bucketByDay([late, early]);
|
||||
expect(m.get('2026-09-03')!.map(x => x.title)).toEqual(['早', '晚']);
|
||||
});
|
||||
|
||||
it('同刻事件用 event_id 兜底定序', () => {
|
||||
const ts = '2026-09-03T09:00:00+08:00';
|
||||
const a = ev({ event_id: 'aaa', event_time: ts });
|
||||
const z = ev({ event_id: 'zzz', event_time: ts });
|
||||
const m1 = bucketByDay([a, z]).get('2026-09-03')!.map(x => x.event_id);
|
||||
const m2 = bucketByDay([z, a]).get('2026-09-03')!.map(x => x.event_id);
|
||||
expect(m1).toEqual(m2);
|
||||
expect(m1).toEqual(['aaa', 'zzz']);
|
||||
});
|
||||
|
||||
it('时间解析失败的脏数据被跳过而不是让整个视图空白', () => {
|
||||
const bad = ev({ event_time: '不是时间' });
|
||||
const good = ev({ event_time: '2026-09-03T09:00:00+08:00' });
|
||||
const m = bucketByDay([bad, good]);
|
||||
expect([...m.keys()]).toEqual(['2026-09-03']);
|
||||
});
|
||||
|
||||
it('空输入给空 Map', () => {
|
||||
expect(bucketByDay([]).size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderReminder', () => {
|
||||
it('替换三个变量', () => {
|
||||
const out = renderReminder('【{title}】{time} — {description}', {
|
||||
title: '发布评审',
|
||||
description: '看 llmsproxy 的部署脚本',
|
||||
event_time: '2026-09-03T14:30:00+08:00'
|
||||
});
|
||||
expect(out).toBe('【发布评审】2026-09-03 14:30 — 看 llmsproxy 的部署脚本');
|
||||
});
|
||||
|
||||
it('同一变量出现多次全部替换', () => {
|
||||
// replaceAll 而非 replace:后者只换第一个
|
||||
const out = renderReminder('{title} / {title}', {
|
||||
title: 'X',
|
||||
event_time: '2026-09-03T14:30:00+08:00'
|
||||
});
|
||||
expect(out).toBe('X / X');
|
||||
});
|
||||
|
||||
it('description 缺失时替换成空串而不是 undefined', () => {
|
||||
const out = renderReminder('{description}|', {
|
||||
title: 'T',
|
||||
event_time: '2026-09-03T14:30:00+08:00'
|
||||
});
|
||||
expect(out).toBe('|');
|
||||
});
|
||||
|
||||
it('没有变量的模板原样返回', () => {
|
||||
const out = renderReminder('纯文本提醒', {
|
||||
title: 'T',
|
||||
event_time: '2026-09-03T14:30:00+08:00'
|
||||
});
|
||||
expect(out).toBe('纯文本提醒');
|
||||
});
|
||||
|
||||
it('时间解析失败时回退到原始串', () => {
|
||||
const out = renderReminder('{time}', { title: 'T', event_time: '坏时间' });
|
||||
expect(out).toBe('坏时间');
|
||||
});
|
||||
});
|
||||
|
||||
describe('datetime-local 往返', () => {
|
||||
it('toLocalInput 给本地时区的 YYYY-MM-DDTHH:mm', () => {
|
||||
const d = new Date(2026, 8, 3, 14, 30);
|
||||
expect(toLocalInput(d)).toBe('2026-09-03T14:30');
|
||||
});
|
||||
|
||||
it('补零', () => {
|
||||
const d = new Date(2026, 0, 5, 9, 5);
|
||||
expect(toLocalInput(d)).toBe('2026-01-05T09:05');
|
||||
});
|
||||
|
||||
it('fromLocalInput 按本地时区解析(人写的就是本地时间)', () => {
|
||||
const iso = fromLocalInput('2026-09-03T14:30');
|
||||
// 结果应与本地构造的 Date 一致
|
||||
expect(iso).toBe(new Date(2026, 8, 3, 14, 30).toISOString());
|
||||
});
|
||||
|
||||
it('往返不丢分钟', () => {
|
||||
const original = new Date(2026, 8, 3, 14, 30);
|
||||
expect(toLocalInput(new Date(fromLocalInput(toLocalInput(original))))).toBe('2026-09-03T14:30');
|
||||
});
|
||||
|
||||
it('非法输入给空串', () => {
|
||||
expect(fromLocalInput('')).toBe('');
|
||||
expect(fromLocalInput('坏值')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('人类可读描述', () => {
|
||||
it('重复规则', () => {
|
||||
expect(describeRecurrence(ev({ recurrence: 'none' }))).toBe('不重复');
|
||||
expect(describeRecurrence(ev({ recurrence: 'daily' }))).toBe('每天');
|
||||
expect(describeRecurrence(ev({ recurrence: 'weekly' }))).toBe('每周');
|
||||
expect(describeRecurrence(ev({ recurrence: 'monthly' }))).toBe('每月');
|
||||
});
|
||||
|
||||
it('带终止时间', () => {
|
||||
const e = ev({ recurrence: 'daily', recurrence_end: '2026-12-31T00:00:00+08:00' });
|
||||
expect(describeRecurrence(e)).toBe('每天,至 2026-12-31');
|
||||
});
|
||||
|
||||
it('none 时忽略 recurrence_end', () => {
|
||||
const e = ev({ recurrence: 'none', recurrence_end: '2026-12-31T00:00:00+08:00' });
|
||||
expect(describeRecurrence(e)).toBe('不重复');
|
||||
});
|
||||
|
||||
it('提前提醒', () => {
|
||||
expect(describeRemindBefore(0)).toBe('到点提醒');
|
||||
expect(describeRemindBefore(15)).toBe('提前 15 分钟');
|
||||
expect(describeRemindBefore(60)).toBe('提前 1 小时');
|
||||
expect(describeRemindBefore(120)).toBe('提前 2 小时');
|
||||
expect(describeRemindBefore(90)).toBe('提前 1 小时 30 分钟');
|
||||
expect(describeRemindBefore(1440)).toBe('提前 24 小时');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user