From 3d53d26ba46a96a8724917882cbb83154bad9e4f Mon Sep 17 00:00:00 2001 From: Maplume <1634378887@qq.com> Date: Tue, 13 Oct 2026 23:04:07 +0800 Subject: [PATCH 1/2] Add month days repeat rule and natural language repeat query --- .../issues/01-month-days.md | 37 ++ .../issues/02-repeat-query.md | 35 ++ .scratch/recurring-tasks-v3/spec.md | 170 +++++++ CONTEXT.md | 6 +- packages/api/src/i18n/locales/en/task.json | 7 + packages/api/src/i18n/locales/zh/task.json | 7 + packages/api/src/utils/repeat-query.test.ts | 277 +++++++++++ .../backend/src/agent/tools/agent-tools.ts | 8 +- packages/backend/src/tasks/dto/tasks.dto.ts | 7 + .../backend/test/agent/agent-tools.spec.ts | 19 + .../test/rest-writes.tasks.e2e-spec.ts | 18 + packages/engine/src/repeat.test.ts | 124 +++++ packages/engine/src/repeat.ts | 44 +- packages/engine/test/domain.test.ts | 13 + packages/engine/test/repeat-project.test.ts | 14 + packages/shared/src/dtos/repeat-rule.dto.ts | 9 +- packages/shared/src/index.ts | 1 + packages/shared/src/query-lexicon.ts | 210 ++++++++ packages/shared/src/repeat-query.ts | 452 ++++++++++++++++++ packages/shared/src/when-query.ts | 233 ++------- .../keyboard/KeyboardFieldPicker.tsx | 1 + .../components/project/ProjectContextMenu.tsx | 2 +- .../src/components/project/ProjectMetaRow.tsx | 4 +- .../src/components/task/SwipeWhenPicker.tsx | 1 + .../src/components/task/TaskContextMenu.tsx | 1 + .../src/components/task/TaskRowExpanded.tsx | 2 + .../task/fields/RepeatRuleField.test.tsx | 36 +- .../task/fields/RepeatRuleField.tsx | 55 ++- .../task/fields/ScheduledDateField.tsx | 11 +- .../task/fields/WhenQueryInput.test.tsx | 69 ++- .../components/task/fields/WhenQueryInput.tsx | 55 ++- .../task/fields/repeatSummary.test.ts | 33 ++ .../components/task/fields/repeatSummary.ts | 15 +- 33 files changed, 1746 insertions(+), 230 deletions(-) create mode 100644 .scratch/recurring-tasks-v3/issues/01-month-days.md create mode 100644 .scratch/recurring-tasks-v3/issues/02-repeat-query.md create mode 100644 .scratch/recurring-tasks-v3/spec.md create mode 100644 packages/api/src/utils/repeat-query.test.ts create mode 100644 packages/shared/src/query-lexicon.ts create mode 100644 packages/shared/src/repeat-query.ts create mode 100644 packages/ui/src/components/task/fields/repeatSummary.test.ts diff --git a/.scratch/recurring-tasks-v3/issues/01-month-days.md b/.scratch/recurring-tasks-v3/issues/01-month-days.md new file mode 100644 index 00000000..3c2b1ad9 --- /dev/null +++ b/.scratch/recurring-tasks-v3/issues/01-month-days.md @@ -0,0 +1,37 @@ +# 01 每月的几号(monthDays) + +Status: implemented + +## Problem + +`unit=month` 只能按计划日期当天推进:「每月 1 号和 15 号」要拆两条链;31 号起算的链钳到短月月末后一直停在 28 / 30 号。 + +## Design + +见 spec 第 1 节。要点: + +- `RepeatRule.monthDays?: number[]`(shared DTO),仅 `unit=month`,1–31 升序去重。 +- `normalizeRepeatRule` 处理 monthDays;`canonicalRepeatRule` 键序 `unit, interval, weekdays?, monthDays?, anchor, until?`,缺省不出现,存量规则的规范串与派生 id 不变。 +- `nextOccurrenceDate` 新增 `nextMonthlyOccurrence`:锚点月为第 0 月,k×interval 月活跃;日子按当月钳制、合并重复;取严格晚于锚点的最小出现日。无 monthDays 时走原路径。 +- REST `RepeatRuleDto` 加 monthDays 校验;助手 `repeatRuleSchema` 与描述、错误提示同步。无数据库迁移(repeatRule 为 JSON 字符串)。 +- `RepeatRuleField`:month 时显示 1–31 日子网格;未选 = 按计划日期当天;离开 month 清除 monthDays。扩展现有 `formatRepeatSummary` 支持 monthDays(02 的候选行复用)。 +- CONTEXT.md 更新 Repeat Rule 词条。 + +## Acceptance + +- engine 测试:规范化、规范串锁定、`nextOccurrenceDate` 矩阵(见 spec Testing),skip / preview / 重复项目各一条 monthDays 用例。 +- backend:DTO 校验、REST 完成派生日期正确、助手参数校验。 +- UI:日子网格选择、切换单位清除、下一次预览正确、`formatRepeatSummary` 中英文。 +- 各包 typecheck 与测试通过。 + +## Comments + +### 2026-10-13 — 实现 + +- engine:`normalizeRepeatRule` / `canonicalRepeatRule` 支持 monthDays;新增 `nextMonthlyOccurrence`(只搜第 0、第 1 个活跃月,后者必有出现日)。无 monthDays 时走原 `addMonthsClamped` 路径。 +- 规范串回归:锁定了 month / year 规则的现有规范串,并验证空 monthDays 与缺省派生同一 id。 +- 后端:`RepeatRuleDto.monthDays`(1–31 校验);助手 `repeatRuleSchema` 与非法规则提示加上 monthDays。 +- UI:`RepeatRuleField` 在 month 时显示 7 列 1–31 网格;未选时提示「按计划日期当天」,选了 29–31 时提示短月落在月末;离开 month 剥离 monthDays。`formatRepeatSummary` 显示「每月 · 1、15 号」,31 显示为「最后一天」。 +- 与 spec 的差异:没有新建 `formatRepeatRule`,复用已有的 `formatRepeatSummary`;规则编辑器顶部不加摘要行(编辑器本身已完整展示规则)。 +- CONTEXT.md 的 Repeat Rule 词条已更新。 +- 测试:engine 310、api 679、ui 1153、backend 401(含临时 Postgres 上的真库 e2e)、desktop 79、mobile 108、frontend 28 全部通过;全仓 typecheck 与改动文件 eslint 通过。尚未在真实应用里手动验证。 diff --git a/.scratch/recurring-tasks-v3/issues/02-repeat-query.md b/.scratch/recurring-tasks-v3/issues/02-repeat-query.md new file mode 100644 index 00000000..57c3bd47 --- /dev/null +++ b/.scratch/recurring-tasks-v3/issues/02-repeat-query.md @@ -0,0 +1,35 @@ +# 02 When 输入框识别重复说法(Repeat Query) + +Status: implemented +Blocked by: 01 + +## Problem + +When 输入框能识别「下周五 9点」,但识别不了「每周五」「每月 15 号」,设重复要先选日期再逐项点规则编辑器。 + +## Design + +见 spec 第 2 节。要点: + +- 新纯函数 `parseRepeatQuery`(`packages/shared/src/repeat-query.ts`),复用 when-query 的归一化、数字 / 星期词表与 `extractTime`;输出规范形规则 + 第一次出现日(≥ 今天)+ 可选时刻。 +- `WhenCandidate` 加入 `repeat` 分支;`parseWhenQuery` 新增 `allowRepeat`,以重复触发词开头时只出重复候选。 +- `ScheduledDateField` / `WhenQueryInput` 新增 `allowRepeat`,只在单个实体的计划日期入口打开(清单见 spec)。 +- 候选行:↻ + `formatRepeatSummary` 摘要 + 「从 X 开始」+ 可选时刻;选中写 `{ start: ANYTIME, scheduledDate, repeatRule, reminderTime? }` 并关闭弹层。 +- i18n 与 placeholder;CONTEXT.md 新增 Repeat Query 词条。 + +## Acceptance + +- 解析器:规则表逐条、中英文、时刻、前缀补全与排序、`allowRepeat: false` 不出重复候选、原有 when-query 测试全部通过、与 engine 交叉校验。 +- UI:重复候选写出的 patch(含 reminderTime、Project 忽略时刻);未开 `allowRepeat` 的入口不出重复候选。 +- 各包 typecheck 与测试通过。 + +## Comments + +### 2026-10-13 — 实现 + +- 重构:把归一化、数字、时刻提取、星期 / 月份名、`exactDate` 从 `when-query.ts` 移到 `packages/shared/src/query-lexicon.ts`,两个解析器共用;原有 122 个 when-query 测试不改即通过。 +- 解析器 `packages/shared/src/repeat-query.ts`:`parseRepeatQuery` 与 `parseRepeat`(后者另给出 `exclusive`)。完全匹配走中英文语法;补全用一张已归一化的说法表做前缀匹配,通用说法(每天 / 每周…)排在具体说法(每周五…)之前,同档按单位、第一次日期排序。第一次出现日在解析器内用 PlainDate 计算,不依赖 engine。 +- `parseWhenQuery`:`allowRepeat` 为真时,若重复说法完全匹配或以 每 / 完成后 / every / each 开头,只出重复候选;否则重复补全接在日期候选之后,总数仍不超过 6。 +- UI:`ScheduledDateField` / `WhenQueryInput` 新增 `allowRepeat`。打开的入口:TaskContextMenu(非多选)、TaskRowExpanded(两处)、SwipeWhenPicker、ProjectContextMenu(单个项目)、ProjectMetaRow、KeyboardFieldPicker(单个目标)。候选行为 ↻ + `formatRepeatSummary` + 「从 X 开始」+ 可选时刻;选中写 `{ start, scheduledDate, repeatRule, reminderTime? }` 并关闭。`allowRepeat` 时占位文字换成「输入日期或重复,如「下周五」「每周一」」。 +- CONTEXT.md 新增 Repeat Query 词条。 +- 测试:api 新增 `repeat-query.test.ts`(105 条:规则表、钳制、非法输入、补全、与 engine 交叉校验、parseWhenQuery 集成);ui 新增 5 条(候选写入、提醒、忽略时刻、未开入口、占位文字)。全仓:engine 310、api 784、ui 1158、backend 401(临时 Postgres 真库)、desktop 79、mobile 108、frontend 28 通过;typecheck、eslint、prettier 通过。尚未在真实应用里手动验证。 diff --git a/.scratch/recurring-tasks-v3/spec.md b/.scratch/recurring-tasks-v3/spec.md new file mode 100644 index 00000000..77409a9d --- /dev/null +++ b/.scratch/recurring-tasks-v3/spec.md @@ -0,0 +1,170 @@ +# Feature: Repeating Tasks v3(重复任务 v3) + +Status: implemented (01–02) — awaiting manual verification + +前作:`.scratch/recurring-tasks/spec.md`(v1)、`.scratch/recurring-tasks-v2/spec.md`(v2)、`.scratch/recurring-projects/spec.md`、ADR-0012;自然语言输入见 `.scratch/when-natural-input/spec.md`。 + +## Problem Statement + +1. **按月只能有一个日子,而且会漂。** 规则 `unit=month` 只能从计划日期那天往后推:「每月 1 号和 15 号发工资 / 对账」只能拆成两条链;31 号起算的链遇到短月钳制到月末后,就一直停在 28 / 30 号,再也回不到 31 号(`addMonthsClamped` 以上一次的日为锚)。 +2. **设重复只能点规则编辑器。** When 输入框已经能识别「下周五 9点」,但「每周五」「每月 15 号」这类重复说法识别不了(when-natural-input spec 的 Out of Scope),设一个重复任务要先选日期、再打开「重复」逐项点选。 + +## Solution + +- **每月的几号(Month Days)**:`unit=month` 的规则新增可选的 `monthDays`(1–31 的集合),活跃月份内这些日子都是出现日;超过当月天数的日子钳制到月末,因此 `31` 即「每月最后一天」,且不会漂移。 +- **自然语言重复(Repeat Query)**:计划日期选择器顶部的 When 输入框能识别重复说法,候选同时写入计划日期(第一次出现日)与重复规则,可带提醒时刻。 + +## User Stories + +1. 作为用户,我想把一个任务设成「每月 1 号和 15 号」,一条链就够了。 +2. 作为用户,我想设「每月最后一天」,二月落在 28 / 29 号,三月回到 31 号。 +3. 作为用户,我想设「每两个月的 10 号」,按计划日期所在的月份起算,隔月出现。 +4. 作为用户,我想在规则编辑器里按月时看到一个 1–31 的日子网格,不选任何日子时保持现在的行为(按计划日期当天)。 +5. 作为用户,跳过本次、下次预告、重复项目、从完成日期起算,对「每月的几号」规则都照常工作。 +6. 作为用户,我想在计划日期输入框里打「每周五」,回车后任务计划在最近的周五(今天是周五就是今天),并带上每周五的规则。 +7. 作为用户,我想打「每周一三五 9点」「每个工作日」「每两周」「每月15号」「每月1号和15号」「每月最后一天」「每年」「每3天」,都能得到对应规则。 +8. 作为用户,我想打「完成后每3天」「3 days after completion」,得到从完成日期起算的规则。 +9. 作为用户,我想用英文打 `every fri`、`daily`、`every 2 weeks on mon`、`every month on the 1st and 15th`、`every weekday`。 +10. 作为用户,打到一半(「每」「every」「每周」)时就能看到候选。 +11. 作为用户,在不能设重复的地方(多选、批量改期、截止日期、快速添加),输入重复说法不会出现重复候选。 +12. 作为用户,我想让助手建出「每月 1 号和 15 号」的任务。 + +## Implementation Decisions + +### 1. 每月的几号(`monthDays`) + +**形状**(`packages/shared/src/dtos/repeat-rule.dto.ts`): + +```ts +interface RepeatRule { + unit: RepeatUnit; + interval: number; + weekdays?: number[]; // 仅 week + monthDays?: number[]; // 仅 month:1–31,升序去重;缺省 = 计划日期当天(现有行为) + anchor: RepeatAnchor; + until?: string | null; +} +``` + +**规范化**(`normalizeRepeatRule`):`monthDays` 仅在 `unit=month` 且过滤后非空时保留;值域 1–31 的整数,升序去重。其它单位一律丢弃。 + +**规范序列化与哈希稳定**(`canonicalRepeatRule`):键序改为 `unit, interval, weekdays?, monthDays?, anchor, until?`。`monthDays` 缺省时不出现在串里,**存量规则的规范串与派生 id 不变**(加回归测试锁住若干现有规则的规范串)。 + +**下一次出现日**(`nextOccurrenceDate`,新增 `nextMonthlyOccurrence`): + +- 锚点所在月为第 0 月,第 k×interval 月为活跃月(同周模式的口径)。 +- 活跃月内,`monthDays` 每个值钳制到当月天数后得到出现日(钳制后重复的合并)。 +- 取严格晚于锚点的最小出现日;搜索范围为锚点所在月起 `interval + 1` 个活跃月,必有结果。 +- 钳制只作用于本月,不改变下一个月的取值:`[31]` 依次得到 1/31、2/28、3/31,**不漂移**。 +- 无 `monthDays` 时完全走原路径(`addMonthsClamped`),行为不变。 + +锚点为完成日期时同理,以完成日为锚。`skipOccurrenceDate`、`buildRepeatPreviews`、`planRepeatProjectInstance` 都经 `nextOccurrenceDate`,不用改。 + +**存储与同步**:`repeatRule` 在 Prisma 中是 JSON 字符串、在 replica 中是 JSON 字段,**无需迁移**。REST DTO `RepeatRuleDto` 增加 `monthDays`(`@IsOptional @IsArray @IsInt({each}) @Min(1) @Max(31)`)。 + +**旧客户端**:旧版 `normalizeRepeatRule` 会丢掉 `monthDays`,用旧语义派生出不同日期与不同 id。客户端版本跟随 hub(ADR-0026),这个窗口只存在于升级前,接受,不做兼容处理。 + +**助手**:`agent-tools.ts` 的 `repeatRuleSchema` 增加 `monthDays`,工具描述与非法规则提示同步更新(「monthDays integers 1-31, unit month only; 31 means the last day of the month」)。 + +**UI**(`RepeatRuleField`): + +- `unit=month` 时,在原来周模式行的位置显示 1–31 的 7 列日子网格(多选,样式同星期按钮),网格下一行小字提示「29–31 在短月份落在月末」。 +- 不选任何日子 = 按计划日期当天(现有行为),网格上方的说明显示「按计划日期(N 号)」。 +- 切换单位时:离开 month 清除 `monthDays`(同离开 week 清除 `weekdays`)。 +- 已有的「下一次:…」预览自动反映新规则。 + +### 2. 自然语言重复(Repeat Query) + +**解析器**(新文件 `packages/shared/src/repeat-query.ts`,纯函数,与 `when-query.ts` 并列,复用其 `normalize`、数字与星期词表、`extractTime`;必要时把这些从 `when-query.ts` 导出为内部共享模块): + +```ts +interface RepeatCandidate { + kind: 'repeat'; + rule: RepeatRule; // 已是规范形 + date: string; // 第一次出现日,YYYY-MM-DD,≥ today + time?: string; // HH:mm +} + +parseRepeatQuery(query: string, options: { today: string; now: string }): RepeatCandidate[] +``` + +- `WhenCandidate` 联合类型加入 `RepeatCandidate`;`parseWhenQuery` 增加选项 `allowRepeat: boolean`,为真时先尝试 `parseRepeatQuery`:**查询以重复触发词开头(每 / 完成后 / every / daily / weekly / monthly / yearly)时只出重复候选**,否则走原有日期规则。 +- 中英文规则同时生效,与界面语言无关。 + +#### 规则 + +| 中文 | 英文 | 规则 | 第一次 | +|---|---|---|---| +| 每天, 每日 | every day, daily | day×1 | 今天 | +| 每3天, 每隔3天¹ | every 3 days | day×3 | 今天 | +| 每周, 每星期 | every week, weekly | week×1(无 weekdays) | 今天 | +| 每两周 | every 2 weeks | week×2 | 今天 | +| 每周五, 每个周五, 每星期五 | every fri, every friday | week×1, [5] | 今天或之后最近的周五 | +| 每周一三五, 每周一、三、五, 每周一和周三 | every mon, wed and fri / every mon/wed/fri | week×1, [1,3,5] | 今天或之后最近的一个 | +| 每两周周三 | every 2 weeks on wed | week×2, [3] | 同上 | +| 工作日, 每个工作日 | every weekday, weekdays | week×1, [1..5] | 同上 | +| 每周末 | every weekend | week×1, [0,6] | 同上 | +| 每月, 每个月 | every month, monthly | month×1(无 monthDays) | 今天 | +| 每月15号, 每月15日 | every month on the 15th, monthly on 15 | month×1, [15] | 今天或之后最近的 15 号(钳制口径) | +| 每月1号和15号, 每月1、15号 | every month on the 1st and 15th | month×1, [1,15] | 同上 | +| 每月最后一天, 每月月底 | every month on the last day, end of every month | month×1, [31] | 本月末 | +| 每两个月10号 | every 2 months on the 10th | month×2, [10] | 同上 | +| 每年, 每一年 | every year, yearly, annually | year×1 | 今天 | +| 每年3月8日 | every year on mar 8 | year×1 | 今天或之后最近的 3/8 | +| 完成后每3天, 完成后3天 | 3 days after completion, every 3 days after completion | day×3, anchor=completion | 今天 | + +¹「每隔 N 天」有「中间空 N 天」(interval=N+1)的读法,歧义大;一律与「每 N 天」同义(interval=N),在测试中锁定。 + +- 未写锚点时 `anchor=scheduled`。 +- 「每年3月8日」不新增规则字段:规则是 `year×1`,锚点就是第一次的计划日期(3/8)。 +- 时刻可附在前后(「每周五9点」「9am every fri」),复用 `extractTime`;时刻非法时整个查询无候选(同 When)。 +- 不支持:`until`(「直到…」「for 3 months」)、「第 N 个周几 / 最后一个周五」、COUNT。 + +#### 第一次出现日 + +在解析器内用 PlainDate 直接计算:无日子集合时为今天;有 weekdays / monthDays 时取今天或之后第一个命中的日子(monthDays 按当月钳制);年度带月日时取今天或之后最近的该月日(2/29 钳制同 v1)。不依赖 `@taskora/engine`(shared 不能反向依赖);测试中与 engine 交叉校验:`normalizeRepeatRule(rule)` 与 `rule` 相等;对 interval=1 的规则,以 `date` 前一天为锚的 `nextOccurrenceDate` 等于 `date`。 + +#### 增量补全 + +- 「每」→ 每天、每周、每月、每年、每个工作日;「每周」→ 每周(今天的星期几)、每周一…(只列最近的几个,凑满 6 个);「每月」→ 每月、每月(今天的号)、每月最后一天。 +- `e` / `ev` / `every` → every day、every week、every month、every year、every weekday;`every f` → every fri。 +- 排序:完全匹配 > 前缀匹配;同档按规则粒度(天 < 周 < 月 < 年)。候选最多 6 个,按规范规则 + 第一次日期去重。 + +**UI**(`WhenQueryInput` / `ScheduledDateField`): + +- `ScheduledDateField` 新增 prop `allowRepeat`(默认 false),透传给 `WhenQueryInput` → `parseWhenQuery`。 +- 只在**单个实体**的计划日期入口打开:`TaskContextMenu`、`TaskRowExpanded`、`ProjectContextMenu`、`ProjectMetaRow`、`SwipeWhenPicker`,以及 `KeyboardFieldPicker` 在只有一个目标时。多选(`MultiSelectToolbar`、多目标的 `KeyboardFieldPicker`)、`SidebarDropProvider`、`QuickAddCard`、截止日期(`DueDateField`)不打开。 +- 候选行:↻ 图标 + 规则摘要(「每周五」「每月 1、15 号」,由现有的 `formatRepeatSummary`(项目页头徽章在用)生成,01 已扩展它支持 monthDays)+ 灰色副标签「从 周五 10月16日 开始」+ 可选时刻。 +- 选中写入:`{ start: ANYTIME, scheduledDate: date, repeatRule: rule }`,带时刻且 `showReminder` 时再加 `reminderTime` 并请求通知权限(与日期候选一致);Project 上忽略时刻。选中后关闭弹层。 +- 已有规则的任务再输入重复说法:直接覆盖规则(规则只影响当前实例及其后代,与在编辑器里改规则同语义)。 + +**i18n**:`task.json`(en / zh)新增 `repeatQueryStartsOn` 等候选行文案(monthDays 相关文案在 01 中加入);When 输入框 placeholder 在 `allowRepeat` 时改为「输入日期或重复,如「下周五」「每周一」」。 + +## Testing Decisions + +- **engine 纯函数(价值最高)**: + - `normalizeRepeatRule`:monthDays 的过滤、排序、去重、非 month 丢弃、空集合丢弃。 + - `canonicalRepeatRule`:现有若干规则的规范串逐字锁定(防 id 漂移);带 monthDays 的键序。 + - `nextOccurrenceDate`:monthDays × interval(1 / 2 / 12)× 锚点在集合内 / 集合外 / 当月最后一个出现日之后;`[31]` 跨 2 月不漂移;`[29,30,31]` 在 2 月合并为一天;闰年;completion 锚点;until。 + - `skipOccurrenceDate`、`buildRepeatPreviews`、`planRepeatProjectInstance` 各补一条 monthDays 用例。 +- **shared 解析器**:固定 today(含今天恰为周五 / 15 号 / 月末 / 2 月)逐条覆盖规则表;中英文;时刻前后置与非法时刻;前缀补全与排序;`allowRepeat: false` 时完全不出重复候选、原有 when-query 测试全部保持通过;与 engine 的交叉校验。 +- **backend**:DTO 接受 / 拒绝 monthDays;REST 完成派生出正确日期;助手工具参数校验。 +- **UI**:`RepeatRuleField` 日子网格的选择与切换单位清除;`WhenQueryInput` 选中重复候选写出的 patch(含 reminderTime / Project 忽略时刻);`allowRepeat` 未开的入口不出重复候选;`formatRepeatSummary` 中英文。 + +## Out of Scope + +- 截止日期按偏移量生成(讨论后决定不做:现有「按计划日期位移平移截止日期」已覆盖主要需求)。 +- 「第 N 个周几 / 最后一个周五」(BYSETPOS)、COUNT、自然语言里的 `until`。 +- 在任务标题里内联识别重复(QuickAdd / Magic Plus 的标题解析),QuickAdd 设置重复。 +- 规则编辑器内单独的自然语言输入框。 +- 旧客户端对 monthDays 的兼容。 + +## Glossary(实现时并入 CONTEXT.md) + +- **Repeat Rule** 定义更新:「单位 × 间隔 × 周模式的星期几集合 **或月模式的日子集合**」;日子超过当月天数时落在月末。 +- **Repeat Query(重复输入)**:计划日期选择器的自然语言输入中识别出的重复说法,选中后同时写入第一次的计划日期与 Repeat Rule。只在单个实体的计划日期入口可用。_Avoid_:重复解析、智能重复。 + +## Issues + +- `issues/01-month-days.md`:规则新增 monthDays(engine、DTO、助手、规则编辑器) +- `issues/02-repeat-query.md`:When 输入框识别重复说法(Blocked by 01) diff --git a/CONTEXT.md b/CONTEXT.md index 31a43d09..41647e01 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -71,9 +71,13 @@ _Avoid_: 闹钟、alarm、通知时间(Reminder 是数据,通知是其触发 _Avoid_: 延后、推迟提醒(口语可用)、本机临时推迟(Snooze 不是设备本地状态) **Repeat Rule(重复规则)**: -Task 或 Project 上的一个结构化规则字段(单位 × 间隔 × 周模式的星期几集合),声明该 Task / Project 完成后按规则再现;锚点默认从计划日期推算,可选从完成日期推算。仅有计划日期的 Task / Project 可设;移入 Someday 或清除计划日期时自动清除。 +Task 或 Project 上的一个结构化规则字段(单位 × 间隔 × 周模式的星期几集合或月模式的日子集合),声明该 Task / Project 完成后按规则再现;月模式的日子超过当月天数时落在月末(31 即每月最后一天),只作用于当月,不漂移;锚点默认从计划日期推算,可选从完成日期推算。仅有计划日期的 Task / Project 可设;移入 Someday 或清除计划日期时自动清除。 _Avoid_: 循环、周期任务、RRULE、模板(无独立模板实体) +**Repeat Query(重复输入)**: +计划日期选择器的自然语言输入中识别出的重复说法(「每周五 9点」「每月1号和15号」「every 2 weeks on wed」)。选中后同时写入第一次出现日(今天或之后)作为计划日期与 Repeat Rule,带时刻且可设提醒时一并写入 Reminder;已有规则直接覆盖。只在单个 Task / Project 的计划日期入口可用,多选、截止日期、快速添加不识别。 +_Avoid_: 重复解析、智能重复 + **Repeat Instance(重复实例)**: 带 Repeat Rule 的 Task 完成时由客户端按规则派生出的下一个 Task,携带相同规则使链得以延续。是普通 Task 而非特殊实体;未来日期落 Upcoming,计划日期已到或已过落 Today。来源有截止日期时,实例的截止日期按计划日期的位移平移(保持提前量,与 Skip Occurrence 同口径),来源没有则实例也没有。派生后即独立:重开(撤销完成 / 撤销取消)来源任务不删除它;实例以 repeatSourceId 记录来源,来源已有未进 Trash 的实例时再次完成不重复派生。 _Avoid_: 副本、克隆(实例是正式任务,不是复制品) diff --git a/packages/api/src/i18n/locales/en/task.json b/packages/api/src/i18n/locales/en/task.json index 956f33fb..5e12f6da 100644 --- a/packages/api/src/i18n/locales/en/task.json +++ b/packages/api/src/i18n/locales/en/task.json @@ -69,6 +69,8 @@ "moveSearchPlaceholder": "Search areas or projects", "moveNoResults": "No matching areas or projects", "whenQueryPlaceholder": "Type a date, e.g. next fri", + "whenQueryRepeatPlaceholder": "Type a date or repeat, e.g. next fri, every mon", + "repeatQueryStartsOn": "starting {{date}}", "deadlineQueryPlaceholder": "Type a deadline, e.g. in 2 weeks", "whenQueryNoResults": "Couldn't understand that date", "convertFailed": "Conversion failed", @@ -98,6 +100,9 @@ "repeatIntervalMinus": "Decrease repeat interval", "repeatIntervalPlus": "Increase repeat interval", "repeatWeekdays": "Repeat weekdays", + "repeatMonthDays": "Repeat days of the month", + "repeatMonthDaysDefault": "With no days selected, repeats on the start date's day", + "repeatMonthDaysClamp": "Days 29–31 fall on the last day of shorter months", "repeatAfterCompletion": "After completion", "repeatUntil": "Until", "repeatNextPreview": "Next: {{date}}", @@ -109,6 +114,8 @@ "repeatSummaryOne-year": "Yearly", "repeatSummaryMany": "Every {{count}} {{unit}}", "repeatWeekdaySeparator": ", ", + "repeatSummaryMonthDays": "day {{days}}", + "repeatSummaryMonthDayLast": "last day", "multiSelectCount": "{{count}} selected", "multiSelectSchedule": "When", "skipOccurrence": "Skip Occurrence", diff --git a/packages/api/src/i18n/locales/zh/task.json b/packages/api/src/i18n/locales/zh/task.json index dcc729ea..a4ac8a75 100644 --- a/packages/api/src/i18n/locales/zh/task.json +++ b/packages/api/src/i18n/locales/zh/task.json @@ -68,6 +68,8 @@ "moveSearchPlaceholder": "搜索区域或项目", "moveNoResults": "没有匹配的区域或项目", "whenQueryPlaceholder": "输入日期,如「下周五」", + "whenQueryRepeatPlaceholder": "输入日期或重复,如「下周五」「每周一」", + "repeatQueryStartsOn": "从{{date}}开始", "deadlineQueryPlaceholder": "输入截止日期,如「两周后」", "whenQueryNoResults": "无法识别的日期", "convertFailed": "转换失败", @@ -97,6 +99,9 @@ "repeatIntervalMinus": "减小重复间隔", "repeatIntervalPlus": "增大重复间隔", "repeatWeekdays": "周模式星期几", + "repeatMonthDays": "每月的几号", + "repeatMonthDaysDefault": "未选日子时按计划日期当天", + "repeatMonthDaysClamp": "29–31 号在短月份落在月末", "repeatAfterCompletion": "从完成日期算", "repeatUntil": "直到", "repeatNextPreview": "下次:{{date}}", @@ -108,6 +113,8 @@ "repeatSummaryOne-year": "每年", "repeatSummaryMany": "每 {{count}} {{unit}}", "repeatWeekdaySeparator": "、", + "repeatSummaryMonthDays": "{{days}} 号", + "repeatSummaryMonthDayLast": "最后一天", "multiSelectCount": "已选择 {{count}} 项", "multiSelectSchedule": "计划", "skipOccurrence": "跳过本次", diff --git a/packages/api/src/utils/repeat-query.test.ts b/packages/api/src/utils/repeat-query.test.ts new file mode 100644 index 00000000..a034a233 --- /dev/null +++ b/packages/api/src/utils/repeat-query.test.ts @@ -0,0 +1,277 @@ +import { describe, expect, it } from 'vitest'; +import { nextOccurrenceDate, normalizeRepeatRule } from '@taskora/engine'; +import { + parseRepeatQuery, + parseWhenQuery, + type RepeatCandidate, + type RepeatRule, + type WhenQueryOptions, +} from '@taskora/shared'; + +// 2026-10-05 是周一 +const base = { today: '2026-10-05', now: '10:00' }; + +function parse(query: string, today = base.today): RepeatCandidate[] { + return parseRepeatQuery(query, { ...base, today }); +} + +/** 第一个候选的「规则 @ 第一次日期 [时刻]」摘要,便于表格断言。 */ +function first(query: string, today?: string): string | undefined { + const c = parse(query, today)[0]; + return c && describeCandidate(c); +} + +function describeCandidate(c: RepeatCandidate): string { + const r = c.rule; + const days = r.weekdays + ? ` w${r.weekdays.join(',')}` + : r.monthDays + ? ` m${r.monthDays.join(',')}` + : ''; + const anchor = r.anchor === 'completion' ? ' after' : ''; + return `${r.unit}×${r.interval}${days}${anchor} @ ${c.date}${c.time ? ` ${c.time}` : ''}`; +} + +describe('parseRepeatQuery: 规则', () => { + it.each([ + // 天 + ['每天', 'day×1 @ 2026-10-05'], + ['每日', 'day×1 @ 2026-10-05'], + ['daily', 'day×1 @ 2026-10-05'], + ['every day', 'day×1 @ 2026-10-05'], + ['每3天', 'day×3 @ 2026-10-05'], + ['每三天', 'day×3 @ 2026-10-05'], + ['每隔3天', 'day×3 @ 2026-10-05'], + ['every 3 days', 'day×3 @ 2026-10-05'], + ['every three days', 'day×3 @ 2026-10-05'], + // 周 + ['每周', 'week×1 @ 2026-10-05'], + ['每星期', 'week×1 @ 2026-10-05'], + ['weekly', 'week×1 @ 2026-10-05'], + ['every week', 'week×1 @ 2026-10-05'], + ['每两周', 'week×2 @ 2026-10-05'], + ['every 2 weeks', 'week×2 @ 2026-10-05'], + ['every other week', 'week×2 @ 2026-10-05'], + ['每周五', 'week×1 w5 @ 2026-10-09'], + ['每个周五', 'week×1 w5 @ 2026-10-09'], + ['每星期五', 'week×1 w5 @ 2026-10-09'], + ['每周日', 'week×1 w0 @ 2026-10-11'], + ['每周天', 'week×1 w0 @ 2026-10-11'], + ['every fri', 'week×1 w5 @ 2026-10-09'], + ['every friday', 'week×1 w5 @ 2026-10-09'], + ['every sunday', 'week×1 w0 @ 2026-10-11'], + // 今天是周一:今天即第一次 + ['每周一', 'week×1 w1 @ 2026-10-05'], + ['每周一三五', 'week×1 w1,3,5 @ 2026-10-05'], + ['每周一、三、五', 'week×1 w1,3,5 @ 2026-10-05'], + ['每周一和周三', 'week×1 w1,3 @ 2026-10-05'], + ['every mon, wed and fri', 'week×1 w1,3,5 @ 2026-10-05'], + ['every tue and thu', 'week×1 w2,4 @ 2026-10-06'], + ['每两周周三', 'week×2 w3 @ 2026-10-07'], + ['every 2 weeks on wed', 'week×2 w3 @ 2026-10-07'], + ['weekly on fri', 'week×1 w5 @ 2026-10-09'], + ['工作日', 'week×1 w1,2,3,4,5 @ 2026-10-05'], + ['每个工作日', 'week×1 w1,2,3,4,5 @ 2026-10-05'], + ['every weekday', 'week×1 w1,2,3,4,5 @ 2026-10-05'], + ['weekdays', 'week×1 w1,2,3,4,5 @ 2026-10-05'], + ['每周末', 'week×1 w0,6 @ 2026-10-10'], + ['every weekend', 'week×1 w0,6 @ 2026-10-10'], + // 月 + ['每月', 'month×1 @ 2026-10-05'], + ['每个月', 'month×1 @ 2026-10-05'], + ['monthly', 'month×1 @ 2026-10-05'], + ['every month', 'month×1 @ 2026-10-05'], + ['每月15号', 'month×1 m15 @ 2026-10-15'], + ['每月15日', 'month×1 m15 @ 2026-10-15'], + ['每月的15号', 'month×1 m15 @ 2026-10-15'], + ['每月十五号', 'month×1 m15 @ 2026-10-15'], + ['every month on the 15th', 'month×1 m15 @ 2026-10-15'], + ['monthly on 15', 'month×1 m15 @ 2026-10-15'], + ['每月5号', 'month×1 m5 @ 2026-10-05'], + ['每月3号', 'month×1 m3 @ 2026-11-03'], + ['每月1号和15号', 'month×1 m1,15 @ 2026-10-15'], + ['每月1、15号', 'month×1 m1,15 @ 2026-10-15'], + ['every month on the 1st and 15th', 'month×1 m1,15 @ 2026-10-15'], + ['每月最后一天', 'month×1 m31 @ 2026-10-31'], + ['每月月底', 'month×1 m31 @ 2026-10-31'], + ['每月15号和最后一天', 'month×1 m15,31 @ 2026-10-15'], + ['every month on the last day', 'month×1 m31 @ 2026-10-31'], + ['end of every month', 'month×1 m31 @ 2026-10-31'], + ['每两个月10号', 'month×2 m10 @ 2026-10-10'], + ['every 2 months on the 10th', 'month×2 m10 @ 2026-10-10'], + // 年 + ['每年', 'year×1 @ 2026-10-05'], + ['yearly', 'year×1 @ 2026-10-05'], + ['annually', 'year×1 @ 2026-10-05'], + ['every year', 'year×1 @ 2026-10-05'], + ['每两年', 'year×2 @ 2026-10-05'], + ['每年3月8日', 'year×1 @ 2027-03-08'], + ['每年十二月一号', 'year×1 @ 2026-12-01'], + ['every year on mar 8', 'year×1 @ 2027-03-08'], + ['every year on 8 march', 'year×1 @ 2027-03-08'], + // 从完成日期算 + ['完成后每3天', 'day×3 after @ 2026-10-05'], + ['完成后3天', 'day×3 after @ 2026-10-05'], + ['完成后每两周', 'week×2 after @ 2026-10-05'], + ['every 3 days after completion', 'day×3 after @ 2026-10-05'], + ['3 days after completion', 'day×3 after @ 2026-10-05'], + // 时刻 + ['每周五9点', 'week×1 w5 @ 2026-10-09 09:00'], + ['每天早上', 'day×1 @ 2026-10-05 09:00'], + ['every fri at 9am', 'week×1 w5 @ 2026-10-09 09:00'], + ['9am every fri', 'week×1 w5 @ 2026-10-09 09:00'], + ['每月1号下午3点', 'month×1 m1 @ 2026-11-01 15:00'], + ])('%s', (query, expected) => { + expect(first(query)).toBe(expected); + }); + + it('短月:日子钳制到月末', () => { + expect(first('每月31号', '2026-02-10')).toBe('month×1 m31 @ 2026-02-28'); + expect(first('每月最后一天', '2028-02-10')).toBe('month×1 m31 @ 2028-02-29'); + expect(first('每月30号', '2026-02-28')).toBe('month×1 m30 @ 2026-02-28'); + expect(first('每年2月29日')).toBe('year×1 @ 2027-02-28'); + }); + + it('非重复说法、非法输入不出候选', () => { + for (const query of [ + '', + 'friday', + '周五', + '明天', + '每周八', + '每月32号', + '每月0号', + 'every 1000 days', + '每天25点', + 'every blah', + '每两个工作日', + ]) { + expect(parse(query), query).toEqual([]); + } + }); +}); + +describe('parseRepeatQuery: 增量补全', () => { + const all = (query: string) => parse(query).map(describeCandidate); + + it('「每」→ 天、周、月、年、工作日', () => { + expect(all('每').slice(0, 5)).toEqual([ + 'day×1 @ 2026-10-05', + 'week×1 @ 2026-10-05', + 'week×1 w1,2,3,4,5 @ 2026-10-05', + 'month×1 @ 2026-10-05', + 'year×1 @ 2026-10-05', + ]); + }); + + it('「每周」→ 完全匹配在前,随后按日期列最近的星期几', () => { + expect(all('每周')).toEqual([ + 'week×1 @ 2026-10-05', + 'week×1 w1 @ 2026-10-05', + 'week×1 w2 @ 2026-10-06', + 'week×1 w3 @ 2026-10-07', + 'week×1 w4 @ 2026-10-08', + 'week×1 w5 @ 2026-10-09', + ]); + }); + + it('「每月」→ 每月、每月 N 号(今天)、每月最后一天', () => { + expect(all('每月')).toEqual([ + 'month×1 @ 2026-10-05', + 'month×1 m5 @ 2026-10-05', + 'month×1 m31 @ 2026-10-31', + ]); + }); + + it('英文前缀', () => { + expect(all('ev').slice(0, 5)).toEqual([ + 'day×1 @ 2026-10-05', + 'week×1 @ 2026-10-05', + 'week×1 w1,2,3,4,5 @ 2026-10-05', + 'month×1 @ 2026-10-05', + 'year×1 @ 2026-10-05', + ]); + expect(all('every f')).toEqual(['week×1 w5 @ 2026-10-09']); + expect(all('dai')).toEqual(['day×1 @ 2026-10-05']); + }); + + it('补全带上已输入的时刻;正在输入的时刻先按去掉它的部分给候选', () => { + expect(all('每周五 9am')).toEqual(['week×1 w5 @ 2026-10-09 09:00']); + expect(all('every fri at 9')[0]).toBe('week×1 w5 @ 2026-10-09 09:00'); + expect(all('每周五 9')[0]).toBe('week×1 w5 @ 2026-10-09'); + }); + + it('最多 6 个,按规则与日期去重', () => { + expect(all('每').length).toBeLessThanOrEqual(6); + const keys = all('every'); + expect(new Set(keys).size).toBe(keys.length); + }); +}); + +describe('parseRepeatQuery: 与 engine 的口径一致', () => { + const queries = [ + '每天', + '每周五', + '每周一三五', + '每两周周三', + '工作日', + '每周末', + '每月15号', + '每月1号和15号', + '每月最后一天', + '每两个月10号', + '每年3月8日', + '完成后每3天', + 'every mon, wed and fri', + 'every month on the 1st and 15th', + ]; + + it.each(queries)('%s:规则已是规范形;第一次日期落在规则的出现日上', (query) => { + const [candidate] = parse(query); + expect(normalizeRepeatRule(candidate.rule)).toEqual(candidate.rule); + const rule: RepeatRule = candidate.rule; + if (rule.interval === 1 && (rule.weekdays || rule.monthDays)) { + const dayBefore = new Date(`${candidate.date}T00:00:00Z`); + dayBefore.setUTCDate(dayBefore.getUTCDate() - 1); + expect( + nextOccurrenceDate(rule, { scheduledDate: dayBefore.toISOString().slice(0, 10) }), + ).toBe(candidate.date); + } + }); +}); + +describe('parseWhenQuery: allowRepeat', () => { + const options: WhenQueryOptions = { + ...base, + weekStartsOn: 1, + allowSomeday: true, + allowRepeat: true, + }; + const kinds = (query: string, overrides: Partial = {}) => + parseWhenQuery(query, { ...options, ...overrides }).map((c) => c.kind); + + it('以重复触发词开头时只出重复候选', () => { + expect(new Set(kinds('每周五'))).toEqual(new Set(['repeat'])); + expect(new Set(kinds('every fri'))).toEqual(new Set(['repeat'])); + expect(new Set(kinds('完成后3天'))).toEqual(new Set(['repeat'])); + expect(parseWhenQuery('每周五 9点', options)[0]).toEqual({ + kind: 'repeat', + rule: { unit: 'week', interval: 1, weekdays: [5], anchor: 'scheduled' }, + date: '2026-10-09', + time: '09:00', + }); + }); + + it('普通日期说法不受影响;重复补全排在日期候选之后', () => { + expect(kinds('fri')).toEqual(kinds('fri', { allowRepeat: false })); + const ev = kinds('ev'); + expect(ev).toContain('repeat'); + expect(ev.indexOf('repeat')).toBeGreaterThanOrEqual(ev.lastIndexOf('date')); + expect(ev.length).toBeLessThanOrEqual(6); + }); + + it('allowRepeat 未开时不出重复候选', () => { + expect(kinds('每周五', { allowRepeat: false })).not.toContain('repeat'); + expect(kinds('every fri', { allowRepeat: undefined })).not.toContain('repeat'); + }); +}); diff --git a/packages/backend/src/agent/tools/agent-tools.ts b/packages/backend/src/agent/tools/agent-tools.ts index 01e06ec0..26e8a7c7 100644 --- a/packages/backend/src/agent/tools/agent-tools.ts +++ b/packages/backend/src/agent/tools/agent-tools.ts @@ -69,6 +69,12 @@ const repeatRuleSchema = Type.Object( description: 'Only for unit "week": days of the week, 0 = Sunday … 6 = Saturday', }), ), + monthDays: Type.Optional( + Type.Array(Type.Integer({ minimum: 1, maximum: 31 }), { + description: + 'Only for unit "month": days of the month, 1-31; a day past the end of a short month falls on its last day, so 31 means the last day of every month. Omit to repeat on the scheduled day.', + }), + ), anchor: Type.Optional( Type.Union([Type.Literal('scheduled'), Type.Literal('completion')], { description: @@ -90,7 +96,7 @@ function parseRepeatRuleParam(value: unknown): RepeatRule { const rule = normalizeRepeatRule({ anchor: 'scheduled', ...(value as object) }); if (!rule) { throw new Error( - 'Invalid repeatRule: unit must be day/week/month/year, interval an integer from 1 to 999, weekdays integers 0-6 (unit week only), anchor "scheduled" or "completion".', + 'Invalid repeatRule: unit must be day/week/month/year, interval an integer from 1 to 999, weekdays integers 0-6 (unit week only), monthDays integers 1-31 (unit month only), anchor "scheduled" or "completion".', ); } return rule; diff --git a/packages/backend/src/tasks/dto/tasks.dto.ts b/packages/backend/src/tasks/dto/tasks.dto.ts index 027aa6e2..610f0722 100644 --- a/packages/backend/src/tasks/dto/tasks.dto.ts +++ b/packages/backend/src/tasks/dto/tasks.dto.ts @@ -34,6 +34,13 @@ export class RepeatRuleDto { @IsInt({ each: true }) weekdays?: number[]; + @IsOptional() + @IsArray() + @IsInt({ each: true }) + @Min(1, { each: true }) + @Max(31, { each: true }) + monthDays?: number[]; + @IsIn(['scheduled', 'completion']) anchor!: 'scheduled' | 'completion'; diff --git a/packages/backend/test/agent/agent-tools.spec.ts b/packages/backend/test/agent/agent-tools.spec.ts index 8eb436a8..065e40f6 100644 --- a/packages/backend/test/agent/agent-tools.spec.ts +++ b/packages/backend/test/agent/agent-tools.spec.ts @@ -426,6 +426,25 @@ describe('AgentToolsService', () => { expect(service.tasks.update).not.toHaveBeenCalled(); }); + it('update_task normalizes monthDays on month rules', async () => { + service.tasks.findOne.mockResolvedValueOnce({ + id: 't1', + start: 'ANYTIME', + scheduledDate: '2026-10-07', + }); + await tool('update_task').execute('call-1', { + id: 't1', + repeatRule: { unit: 'month', interval: 1, monthDays: [15, 1, 1] }, + } as never); + expect(service.tasks.update).toHaveBeenLastCalledWith( + 'user-1', + 't1', + expect.objectContaining({ + repeatRule: { unit: 'month', interval: 1, monthDays: [1, 15], anchor: 'scheduled' }, + }), + ); + }); + it('update_task skipOccurrence skips after applying field updates', async () => { const result = await tool('update_task').execute('call-1', { id: 't1', diff --git a/packages/backend/test/rest-writes.tasks.e2e-spec.ts b/packages/backend/test/rest-writes.tasks.e2e-spec.ts index 9ab5e58e..03b4eb1c 100644 --- a/packages/backend/test/rest-writes.tasks.e2e-spec.ts +++ b/packages/backend/test/rest-writes.tasks.e2e-spec.ts @@ -457,6 +457,24 @@ dbDescribe('TasksService 写路径(真实 Postgres)', () => { expect(instances.map((task) => task.scheduledDate)).toEqual([new Date('2026-09-25T00:00:00Z')]); }); + it('monthDays 规则:规范形落库,完成后派生到集合中的下一个日子', async () => { + await seedTask('payday', { + start: 'ANYTIME', + scheduledDate: new Date('2026-01-31T00:00:00Z'), + }); + await h.tasks.update(USER, 'payday', { + repeatRule: { unit: 'month', interval: 1, monthDays: [31, 15, 15], anchor: 'scheduled' }, + }); + const row = await testPrisma.task.findUniqueOrThrow({ where: { id: 'payday' } }); + expect(row.repeatRule).toBe( + '{"unit":"month","interval":1,"monthDays":[15,31],"anchor":"scheduled"}', + ); + + await h.tasks.complete(USER, 'payday'); + const instances = await testPrisma.task.findMany({ where: { repeatSourceId: 'payday' } }); + expect(instances.map((task) => task.scheduledDate)).toEqual([new Date('2026-02-15T00:00:00Z')]); + }); + it('不派生:实例已存在 / 到达 until / 无规则 / 已完成再完成 / 取消', async () => { await seedRepeating(); await seedTask(expectedInstanceId, { title: '已存在的实例' }); diff --git a/packages/engine/src/repeat.test.ts b/packages/engine/src/repeat.test.ts index 3dbc96c1..f874484f 100644 --- a/packages/engine/src/repeat.test.ts +++ b/packages/engine/src/repeat.test.ts @@ -44,6 +44,30 @@ describe('normalizeRepeatRule — 规范形(派生 id 的稳定输入)', () ).toEqual({ unit: 'day', interval: 1, anchor: 'scheduled' }); }); + it('monthDays 升序去重、仅 month 单位保留、值域 1-31;空集合视为未设置', () => { + expect( + normalizeRepeatRule({ + unit: 'month', + interval: 1, + monthDays: [15, 1, 15, 0, 32, 2.5, 31], + anchor: 'scheduled', + }), + ).toEqual({ unit: 'month', interval: 1, monthDays: [1, 15, 31], anchor: 'scheduled' }); + expect( + normalizeRepeatRule({ unit: 'month', interval: 1, monthDays: [], anchor: 'scheduled' }), + ).toEqual({ unit: 'month', interval: 1, anchor: 'scheduled' }); + expect( + normalizeRepeatRule({ unit: 'month', interval: 1, monthDays: [0, 40], anchor: 'scheduled' }), + ).toEqual({ unit: 'month', interval: 1, anchor: 'scheduled' }); + // 非 month 单位:monthDays 一律剥离 + expect( + normalizeRepeatRule({ unit: 'week', interval: 1, monthDays: [1], anchor: 'scheduled' }), + ).toEqual({ unit: 'week', interval: 1, anchor: 'scheduled' }); + expect( + normalizeRepeatRule({ unit: 'year', interval: 1, monthDays: [1], anchor: 'scheduled' }), + ).toEqual({ unit: 'year', interval: 1, anchor: 'scheduled' }); + }); + it('until 归一为 YYYY-MM-DD;无效 until 剥离', () => { expect( normalizeRepeatRule({ unit: 'day', interval: 1, anchor: 'scheduled', until: '2026-03-01' }), @@ -272,6 +296,82 @@ describe('nextOccurrenceDate — 规则 → 下一次出现日期(纯函数) }); }); +describe('nextOccurrenceDate — monthDays(每月的几号)', () => { + const next = (r: RepeatRule, scheduledDate: string) => nextOccurrenceDate(r, { scheduledDate }); + + it('多个日子:取严格晚于锚点的下一个,跨月回到第一个', () => { + const r = rule({ unit: 'month', monthDays: [1, 15] }); + expect(next(r, '2026-03-01')).toBe('2026-03-15'); + expect(next(r, '2026-03-15')).toBe('2026-04-01'); + // 锚点不在集合内:取锚点之后最近的出现日 + expect(next(r, '2026-03-10')).toBe('2026-03-15'); + expect(next(r, '2026-03-20')).toBe('2026-04-01'); + }); + + it('[31] 在短月落在月末,下一个月回到 31 号,不漂移', () => { + const r = rule({ unit: 'month', monthDays: [31] }); + expect(next(r, '2026-01-31')).toBe('2026-02-28'); + expect(next(r, '2026-02-28')).toBe('2026-03-31'); + expect(next(r, '2026-03-31')).toBe('2026-04-30'); + expect(next(r, '2026-04-30')).toBe('2026-05-31'); + // 闰年 + expect(next(r, '2028-01-31')).toBe('2028-02-29'); + }); + + it('钳制后重复的日子合并:[29,30,31] 在 2 月只出现一次', () => { + const r = rule({ unit: 'month', monthDays: [29, 30, 31] }); + expect(next(r, '2026-01-31')).toBe('2026-02-28'); + expect(next(r, '2026-02-28')).toBe('2026-03-29'); + expect(next(r, '2026-03-29')).toBe('2026-03-30'); + }); + + it('interval > 1:锚点所在月为第 0 月,只在活跃月出现', () => { + const r = rule({ unit: 'month', interval: 2, monthDays: [10, 20] }); + expect(next(r, '2026-03-10')).toBe('2026-03-20'); + expect(next(r, '2026-03-20')).toBe('2026-05-10'); + // 锚点在本月最后一个出现日之后 + expect(next(r, '2026-03-25')).toBe('2026-05-10'); + const yearly = rule({ unit: 'month', interval: 12, monthDays: [5] }); + expect(next(yearly, '2026-03-05')).toBe('2027-03-05'); + expect(next(yearly, '2026-03-01')).toBe('2026-03-05'); + }); + + it('跨年', () => { + const r = rule({ unit: 'month', monthDays: [1, 15] }); + expect(next(r, '2026-12-15')).toBe('2027-01-01'); + }); + + it('until 截断:超过 until 返回 null', () => { + const r = rule({ unit: 'month', monthDays: [1, 15], until: '2026-03-14' }); + expect(next(r, '2026-03-01')).toBeNull(); + }); + + it('completion 锚点:以完成日为锚', () => { + const r = rule({ unit: 'month', monthDays: [1, 15], anchor: 'completion' }); + expect( + nextOccurrenceDate(r, { + scheduledDate: '2026-03-01', + settledAt: '2026-03-20T10:00:00.000Z', + }), + ).toBe('2026-04-01'); + }); + + it('无 monthDays 的按月规则行为不变(按锚点日,钳制会延续)', () => { + const r = rule({ unit: 'month' }); + expect(next(r, '2026-01-31')).toBe('2026-02-28'); + expect(next(r, '2026-02-28')).toBe('2026-03-28'); + }); + + it('skipOccurrenceDate 沿 monthDays 跳过全部错过的出现日', () => { + expect( + skipOccurrenceDate(rule({ unit: 'month', monthDays: [1, 15] }), { + scheduledDate: '2026-01-01', + now: '2026-03-10T12:00:00.000Z', + }), + ).toBe('2026-03-15'); + }); +}); + describe('skipOccurrenceDate — 跳过本次的目标日期', () => { // 今天 = 2026-02-10(周二,UTC) const now = '2026-02-10T08:00:00.000Z'; @@ -381,6 +481,30 @@ describe('确定性 id 派生(ADR-0012)', () => { expect(deriveSubtaskId('instance-2', 0)).not.toBe(deriveSubtaskId('instance-1', 0)); }); + it('canonicalRepeatRule:存量规则的规范串逐字不变(派生 id 不漂移)', () => { + expect(canonicalRepeatRule(rule({ unit: 'month' }))).toBe( + '{"unit":"month","interval":1,"anchor":"scheduled"}', + ); + expect(canonicalRepeatRule(rule({ unit: 'year', interval: 2, anchor: 'completion' }))).toBe( + '{"unit":"year","interval":2,"anchor":"completion"}', + ); + expect(deriveRepeatInstanceId('task-1', rule({ unit: 'month' }), '2026-02-02')).toBe( + deriveRepeatInstanceId( + 'task-1', + normalizeRepeatRule({ unit: 'month', interval: 1, monthDays: [], anchor: 'scheduled' })!, + '2026-02-02', + ), + ); + }); + + it('canonicalRepeatRule:monthDays 位于 weekdays 之后、anchor 之前', () => { + expect( + canonicalRepeatRule(rule({ unit: 'month', monthDays: [15, 1], until: '2026-12-31' })), + ).toBe( + '{"unit":"month","interval":1,"monthDays":[1,15],"anchor":"scheduled","until":"2026-12-31"}', + ); + }); + it('canonicalRepeatRule:键序固定(unit,interval,weekdays?,anchor,until?)', () => { expect(canonicalRepeatRule(rule({ unit: 'week', weekdays: [1, 3] }))).toBe( '{"unit":"week","interval":1,"weekdays":[1,3],"anchor":"scheduled"}', diff --git a/packages/engine/src/repeat.ts b/packages/engine/src/repeat.ts index df215168..b0199281 100644 --- a/packages/engine/src/repeat.ts +++ b/packages/engine/src/repeat.ts @@ -28,6 +28,7 @@ const UNITS: ReadonlySet = new Set(['day', 'week', 'month', 'year']) * - unit ∈ {day, week, month, year}; * - interval 为 ≥1 的整数; * - weekdays 仅在 unit=week 且非空时存在,升序去重、值域 0-6(0=周日); + * - monthDays 仅在 unit=month 且非空时存在,升序去重、值域 1-31; * - anchor 恒存在(缺失默认 scheduled); * - until 仅在合法日期时存在,归一为 YYYY-MM-DD。 */ @@ -59,6 +60,17 @@ export function normalizeRepeatRule(input: unknown): RepeatRule | null { if (weekdays.length > 0) rule.weekdays = weekdays; } + if (unit === 'month' && Array.isArray(raw.monthDays)) { + const monthDays = [ + ...new Set( + raw.monthDays.filter( + (d): d is number => typeof d === 'number' && Number.isInteger(d) && d >= 1 && d <= 31, + ), + ), + ].sort((a, b) => a - b); + if (monthDays.length > 0) rule.monthDays = monthDays; + } + if (typeof raw.until === 'string') { const until = dayKey(raw.until); if (until !== null) rule.until = until; @@ -71,12 +83,14 @@ export function normalizeRepeatRule(input: unknown): RepeatRule | null { export function canonicalRepeatRule(rule: RepeatRule): string { const normalized = normalizeRepeatRule(rule); if (normalized === null) throw new Error('canonicalRepeatRule: 非法规则'); - // 键序即构造序:unit, interval, weekdays?, anchor, until? + // 键序即构造序:unit, interval, weekdays?, monthDays?, anchor, until? + // 可选键缺省时不出现,新增可选键不改变存量规则的规范串(派生 id 稳定) const parts: string[] = [ `"unit":${JSON.stringify(normalized.unit)}`, `"interval":${normalized.interval}`, ]; if (normalized.weekdays) parts.push(`"weekdays":${JSON.stringify(normalized.weekdays)}`); + if (normalized.monthDays) parts.push(`"monthDays":${JSON.stringify(normalized.monthDays)}`); parts.push(`"anchor":${JSON.stringify(normalized.anchor)}`); if (normalized.until) parts.push(`"until":${JSON.stringify(normalized.until)}`); return `{${parts.join(',')}}`; @@ -184,7 +198,7 @@ export function nextOccurrenceDate( next = nextWeeklyOccurrence(anchor, normalized); break; case 'month': - next = addMonthsClamped(anchor, normalized.interval); + next = nextMonthlyOccurrence(anchor, normalized); break; case 'year': next = addMonthsClamped(anchor, normalized.interval * 12); @@ -262,6 +276,32 @@ function nextWeeklyOccurrence(anchor: Date, rule: RepeatRule): Date { return addDays(anchor, rule.interval * 7); } +/** + * month 单位的下一次出现:无 monthDays 时为「锚点 + interval 月」(日溢出 + * 钳制到月末)。有 monthDays 时锚点所在月为第 0 月,第 k×interval 月为 + * 活跃月;活跃月内每个日子钳制到当月天数后都是出现日,取锚点之后(不含) + * 的最小出现日。钳制只作用于当月,[31] 下个月仍回到 31 号,不漂移。 + */ +function nextMonthlyOccurrence(anchor: Date, rule: RepeatRule): Date { + if (!rule.monthDays || rule.monthDays.length === 0) { + return addMonthsClamped(anchor, rule.interval); + } + const anchorKey = keyOf(anchor); + // 第 0 个活跃月若已无晚于锚点的出现日,第 1 个活跃月必有 + for (let k = 0; k <= 1; k += 1) { + const total = anchor.getUTCFullYear() * 12 + anchor.getUTCMonth() + k * rule.interval; + const year = Math.floor(total / 12); + const month = total % 12; + const lastDay = daysInMonth(year, month); + for (const day of rule.monthDays) { + const candidate = new Date(Date.UTC(year, month, Math.min(day, lastDay))); + if (keyOf(candidate) > anchorKey) return candidate; + } + } + // 理论不可达;防御性返回 + return addMonthsClamped(anchor, rule.interval); +} + // ---------- 确定性 id 派生(ADR-0012) ---------- /** FNV-1a 32 位(Math.imul 保持 32 位语义,跨端确定)。 */ diff --git a/packages/engine/test/domain.test.ts b/packages/engine/test/domain.test.ts index 5a7a5c7e..cad8f2b4 100644 --- a/packages/engine/test/domain.test.ts +++ b/packages/engine/test/domain.test.ts @@ -744,6 +744,19 @@ describe('下次预告(Repeat Preview)', () => { ]); }); + it('monthDays 规则投影到集合中的下一个日子', () => { + expect( + buildRepeatPreviews( + [ + source('payday', { + repeatRule: { unit: 'month', interval: 1, monthDays: [10, 25], anchor: 'scheduled' }, + }), + ], + context, + ).map((p) => [p.sourceTaskId, p.dateKey]), + ).toEqual([['payday', '2026-02-25']]); + }); + it('不投影:已了结 / Trash / 无计划日期 / 无规则 / completion 锚点 / 链终结 / 下一次不晚于今天', () => { expect( buildRepeatPreviews( diff --git a/packages/engine/test/repeat-project.test.ts b/packages/engine/test/repeat-project.test.ts index 52595c7c..9bb4d91a 100644 --- a/packages/engine/test/repeat-project.test.ts +++ b/packages/engine/test/repeat-project.test.ts @@ -81,6 +81,20 @@ describe('重复项目派生', () => { }); }); + it('monthDays 规则:计划到集合中的下一个日子,截止日期按同一位移平移', () => { + const rule = { unit: 'month', interval: 1, monthDays: [5, 20], anchor: 'scheduled' } as const; + const plan = planRepeatProjectInstance( + { ...parent, repeatRule: rule }, + '2026-10-05T10:00:00.000Z', + UTC, + REVIEW, + )!; + expect(plan.id).toBe(deriveRepeatProjectId('project-1', rule, '2026-10-20')); + expect(plan.project.scheduledDate).toBe('2026-10-20'); + expect(plan.project.dueDate).toBe('2026-10-22'); + expect(plan.project.repeatRule).toEqual(rule); + }); + it('无规则或链已终结(until)不派生', () => { expect(planRepeatProjectInstance({ ...parent, repeatRule: null }, 'x', UTC, REVIEW)).toBeNull(); expect( diff --git a/packages/shared/src/dtos/repeat-rule.dto.ts b/packages/shared/src/dtos/repeat-rule.dto.ts index bb46a3bc..d1658e2d 100644 --- a/packages/shared/src/dtos/repeat-rule.dto.ts +++ b/packages/shared/src/dtos/repeat-rule.dto.ts @@ -2,7 +2,8 @@ * Repeat Rule(重复规则)— Task / Project 上的结构化规则字段(recurring-tasks / * recurring-projects spec,ADR-0012)。 * - * 声明该 Task / Project 完成后按规则再现:单位 × 间隔 × 周模式的星期几集合, + * 声明该 Task / Project 完成后按规则再现:单位 × 间隔 × 周模式的星期几集合 + * 或月模式的日子集合, * 锚点默认从计划日期(Scheduled Date)推算,可选从完成日期推算。 * 仅有计划日期的 Task / Project 可设。 * @@ -24,6 +25,12 @@ export interface RepeatRule { * 缺省表示「与锚点相同的星期几」。规范形:升序去重;非 week 单位不携带。 */ weekdays?: number[]; + /** + * 月模式(仅 unit=month 有意义):1–31 的日子集合,活跃月内这些日子都是出现日; + * 超过当月天数的日子落在月末(31 即每月最后一天)。缺省表示「与锚点相同的日」。 + * 规范形:升序去重;非 month 单位不携带。 + */ + monthDays?: number[]; anchor: RepeatAnchor; /** 直到日期(含当天,YYYY-MM-DD):之后的再现终止链。缺省/null 表示无限。 */ until?: string | null; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 08ea41c7..60ea8336 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,6 +1,7 @@ export * from './calendar-date'; export * from './later-project'; export * from './when-query'; +export * from './repeat-query'; export * from './review'; export * from './new-in-today'; export * from './logging'; diff --git a/packages/shared/src/query-lexicon.ts b/packages/shared/src/query-lexicon.ts new file mode 100644 index 00000000..399ca444 --- /dev/null +++ b/packages/shared/src/query-lexicon.ts @@ -0,0 +1,210 @@ +import { Temporal } from '@js-temporal/polyfill'; + +/** + * 自然语言输入的共享词法(When 日期输入 `when-query`、重复输入 `repeat-query`): + * 归一化、中英文数字、时刻提取、星期与月份名、严格日期构造。 + */ + +// ─── 归一化 ──────────────────────────────────────────────────────────── + +export const CJK = '\\u3400-\\u9fff'; + +export function normalize(query: string): string { + return query + .normalize('NFKC') + .toLowerCase() + .replace(/[,。、]/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .replace(new RegExp(`(?<=[${CJK}])\\s+|\\s+(?=[${CJK}])`, 'g'), ''); +} + +// ─── 数字 ───────────────────────────────────────────────────────────── + +const ZH_DIGITS: Record = { + 零: 0, + 〇: 0, + 一: 1, + 二: 2, + 两: 2, + 三: 3, + 四: 4, + 五: 5, + 六: 6, + 七: 7, + 八: 8, + 九: 9, +}; +export const ZH_NUM = '[零〇一二两三四五六七八九十廿卅]+'; +export const NUM = `(?:\\d{1,2}|${ZH_NUM})`; + +/** 阿拉伯数字或一到九十九的中文数字(含「两」「廿」「卅」)。 */ +export function toNumber(text: string): number | null { + if (/^\d+$/.test(text)) return Number(text); + if (text.length === 1 && text in ZH_DIGITS) return ZH_DIGITS[text]; + const m = /^([一二三四五六七八九]?)([十廿卅])([一二三四五六七八九]?)$/.exec(text); + if (!m) return null; + const ones = m[3] ? ZH_DIGITS[m[3]] : 0; + if (m[2] === '十') return (m[1] ? ZH_DIGITS[m[1]] : 1) * 10 + ones; + if (m[1]) return null; // 「二廿」不成立 + return (m[2] === '廿' ? 20 : 30) + ones; +} + +export const EN_NUMBERS: Record = { + a: 1, + an: 1, + one: 1, + two: 2, + three: 3, + four: 4, + five: 5, + six: 6, + seven: 7, + eight: 8, + nine: 9, + ten: 10, + eleven: 11, + twelve: 12, +}; + +// ─── 时刻 ───────────────────────────────────────────────────────────── + +export type Period = 'am' | 'pm' | 'noon'; + +export interface TimeResult { + rest: string; + time?: string; + impliesToday?: boolean; +} + +/** 正在输入、尚不完整的时刻尾巴:`at`、`9`、`9:`、`9:3`。 */ +export const PARTIAL_TIME_TAIL = /\s*(?:(?:\bat\s+|@\s*)?\d{1,2}(?::\d?)?|\bat|@)$/; + +const ZH_PERIODS: Record = { + 凌晨: { period: 'am', standalone: '' }, + 早上: { period: 'am', standalone: '09:00' }, + 早晨: { period: 'am', standalone: '09:00' }, + 上午: { period: 'am', standalone: '09:00' }, + 中午: { period: 'noon', standalone: '12:00' }, + 下午: { period: 'pm', standalone: '14:00' }, + 傍晚: { period: 'pm', standalone: '19:00' }, + 晚上: { period: 'pm', standalone: '19:00' }, + 今晚: { period: 'pm', standalone: '20:00', today: true }, +}; +const EN_PERIODS: Record = { + morning: { period: 'am', standalone: '09:00' }, + afternoon: { period: 'pm', standalone: '14:00' }, + evening: { period: 'pm', standalone: '19:00' }, + tonight: { period: 'pm', standalone: '20:00', today: true }, +}; + +function hhmm(hour: number, minute: number): string { + return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`; +} + +/** 按 am / pm 修正小时;不合法返回 null。 */ +function resolveHour(hour: number, minute: number, period: Period | undefined): string | null { + if (minute < 0 || minute > 59) return null; + if (!period) return hour <= 23 ? hhmm(hour, minute) : null; + if (hour < 1 || hour > 12) return period === 'am' && hour === 0 ? hhmm(0, minute) : null; + if (period === 'am') return hhmm(hour % 12, minute); + if (period === 'noon') return hhmm(hour <= 2 ? hour + 12 : hour, minute); + return hhmm(hour === 12 ? 12 : hour + 12, minute); +} + +export function extractTime(text: string): TimeResult | 'invalid' { + const cut = (m: RegExpExecArray) => + (text.slice(0, m.index) + ' ' + text.slice(m.index + m[0].length)).replace(/\s+/g, ' ').trim(); + const periodNames = Object.keys(ZH_PERIODS).join('|'); + + // 中文:[时段]N点[半|一刻|三刻|M分] + const zh = new RegExp(`(${periodNames})?(${NUM})[点點时](半|一刻|三刻|(${NUM})分?)?`).exec(text); + if (zh) { + const hour = toNumber(zh[2]); + const minute = + zh[3] === '半' + ? 30 + : zh[3] === '一刻' + ? 15 + : zh[3] === '三刻' + ? 45 + : zh[4] + ? toNumber(zh[4]) + : 0; + const info = zh[1] ? ZH_PERIODS[zh[1]] : undefined; + if (hour === null || minute === null) return 'invalid'; + const time = resolveHour(hour, minute, info?.period); + return time ? { rest: cut(zh), time, impliesToday: info?.today } : 'invalid'; + } + + // 英文 / 通用:9am、9:30pm、at 9、21:00 + const enPeriod = new RegExp(`\\b(?:this\\s+)?(${Object.keys(EN_PERIODS).join('|')})\\b`).exec( + text, + ); + const numeric = + /(?:\bat\s+|@\s*)?\b(\d{1,2})(?::(\d{2}))?\s*(a\.?m\.?|p\.?m\.?|a|p)(?![a-z])/.exec(text) ?? + /(?:\bat\s+|@\s*)(\d{1,2})(?::(\d{2}))?\b/.exec(text) ?? + /\b(\d{1,2}):(\d{2})\b/.exec(text); + if (numeric) { + const suffix = numeric[3]?.[0]; + const fromWord = enPeriod ? EN_PERIODS[enPeriod[1]] : undefined; + const period: Period | undefined = + suffix === 'a' ? 'am' : suffix === 'p' ? 'pm' : fromWord?.period; + const time = resolveHour(Number(numeric[1]), Number(numeric[2] ?? 0), period); + if (!time) return 'invalid'; + let rest = cut(numeric); + if (enPeriod && !suffix) rest = rest.replace(enPeriod[0], ' ').replace(/\s+/g, ' ').trim(); + return { rest, time, impliesToday: fromWord?.today && !suffix }; + } + + const word = /\b(noon|midnight)\b/.exec(text); + if (word) return { rest: cut(word), time: word[1] === 'noon' ? '12:00' : '00:00' }; + if (enPeriod) { + const info = EN_PERIODS[enPeriod[1]]; + return { rest: cut(enPeriod), time: info.standalone, impliesToday: info.today }; + } + + const zhWord = new RegExp(`(午夜|${periodNames})`).exec(text); + if (zhWord) { + if (zhWord[1] === '午夜') return { rest: cut(zhWord), time: '00:00' }; + const info = ZH_PERIODS[zhWord[1]]; + if (info.standalone) + return { rest: cut(zhWord), time: info.standalone, impliesToday: info.today }; + } + return { rest: text }; +} + +// ─── 名称表 ─────────────────────────────────────────────────────────── + +export const EN_WEEKDAYS: string[][] = [ + ['monday', 'mon'], + ['tuesday', 'tue', 'tues'], + ['wednesday', 'wed'], + ['thursday', 'thu', 'thur', 'thurs'], + ['friday', 'fri'], + ['saturday', 'sat'], + ['sunday', 'sun'], +]; +export const ZH_WEEKDAYS = ['一', '二', '三', '四', '五', '六', '日|天']; +export const EN_MONTHS = [ + 'january', + 'february', + 'march', + 'april', + 'may', + 'june', + 'july', + 'august', + 'september', + 'october', + 'november', + 'december', +]; + +export function exactDate(year: number, month: number, day: number): Temporal.PlainDate | null { + try { + return Temporal.PlainDate.from({ year, month, day }, { overflow: 'reject' }); + } catch { + return null; + } +} diff --git a/packages/shared/src/repeat-query.ts b/packages/shared/src/repeat-query.ts new file mode 100644 index 00000000..a720de7a --- /dev/null +++ b/packages/shared/src/repeat-query.ts @@ -0,0 +1,452 @@ +import { Temporal } from '@js-temporal/polyfill'; + +import type { RepeatAnchor, RepeatRule, RepeatUnit } from './dtos/repeat-rule.dto'; +import { + EN_MONTHS, + EN_NUMBERS, + EN_WEEKDAYS, + NUM, + PARTIAL_TIME_TAIL, + ZH_WEEKDAYS, + exactDate, + extractTime, + normalize, + toNumber, +} from './query-lexicon'; + +/** + * 计划日期输入框里的重复说法(`.scratch/recurring-tasks-v3` 的 Repeat Query): + * 「每周五 9点」「每月1号和15号」「every 2 weeks on wed」→ 规范形 Repeat Rule + * + 第一次出现日(今天或之后)+ 可选时刻。纯函数,只在 date key 上计算, + * today / now 由调用方按账号时区传入;中英文规则同时生效。 + */ +export interface RepeatQueryOptions { + /** 账号时区的今天,YYYY-MM-DD。 */ + today: string; + /** 账号时区的当前时刻,HH:mm。 */ + now: string; +} + +export interface RepeatCandidate { + kind: 'repeat'; + /** 已是规范形(与 engine normalizeRepeatRule 的输出一致)。 */ + rule: RepeatRule; + /** 第一次出现日,YYYY-MM-DD,不早于今天。 */ + date: string; + time?: string; +} + +const MAX_CANDIDATES = 6; +const MAX_INTERVAL = 999; +const UNIT_RANK: Record = { day: 0, week: 1, month: 2, year: 3 }; +const WORKDAYS = [1, 2, 3, 4, 5]; +const WEEKEND = [0, 6]; + +type PlainDate = Temporal.PlainDate; + +/** 解析出的规则意图;year 可带第一次的月日(规则本身不存月日,锚点即第一次)。 */ +interface RepeatSpec { + unit: RepeatUnit; + interval: number; + weekdays?: number[]; + monthDays?: number[]; + yearDate?: { month: number; day: number }; + anchor: RepeatAnchor; +} + +interface Match { + rule: RepeatRule; + date: string; + exact: boolean; + /** 0 = 通用说法(每天 / 每周…),1 = 具体说法(每周五…);补全时通用的在前。 */ + priority: number; +} + +export function parseRepeatQuery(query: string, options: RepeatQueryOptions): RepeatCandidate[] { + return parseRepeat(query, options).candidates; +} + +/** + * 同 parseRepeatQuery,另给出 exclusive:有完全匹配或以重复触发词开头时为真, + * parseWhenQuery 据此只出重复候选。 + */ +export function parseRepeat( + query: string, + options: RepeatQueryOptions, +): { candidates: RepeatCandidate[]; exclusive: boolean } { + const none = { candidates: [], exclusive: false }; + const text = normalize(query); + if (!text) return none; + + const extracted = extractTime(text); + if (extracted === 'invalid') return none; + const { time } = extracted; + let rest = extracted.rest; + if (!rest) return none; + + const today = Temporal.PlainDate.from(options.today); + let matches = collect(rest, today); + // 正在输入的时刻(`每周五 9`)尚不完整时,先按去掉它的部分给候选 + if (matches.length === 0 && !time) { + const trimmed = rest.replace(PARTIAL_TIME_TAIL, '').trim(); + if (trimmed && trimmed !== rest) { + rest = trimmed; + matches = collect(rest, today); + } + } + + return { + candidates: rank(matches, time), + exclusive: matches.some((m) => m.exact) || TRIGGER.test(rest), + }; +} + +const TRIGGER = /^(?:每|完成后|every\b|each\b)/; + +function collect(text: string, today: PlainDate): Match[] { + const matches: Match[] = []; + const add = (spec: RepeatSpec | null, exact: boolean, priority: number) => { + if (spec) matches.push({ ...resolve(spec, today), exact, priority }); + }; + add(parseSpec(text), true, 0); + for (const [phrase, priority] of completionPhrases(today)) { + if (phrase !== text && phrase.startsWith(text)) add(parseSpec(phrase), false, priority); + } + return matches; +} + +function rank(matches: Match[], time: string | undefined): RepeatCandidate[] { + const sorted = matches + .map((m, index) => ({ ...m, index })) + .sort( + (a, b) => + Number(b.exact) - Number(a.exact) || + a.priority - b.priority || + UNIT_RANK[a.rule.unit] - UNIT_RANK[b.rule.unit] || + a.date.localeCompare(b.date) || + a.index - b.index, + ); + const seen = new Set(); + const out: RepeatCandidate[] = []; + for (const m of sorted) { + const key = `${JSON.stringify(m.rule)}@${m.date}`; + if (seen.has(key)) continue; + seen.add(key); + out.push( + time + ? { kind: 'repeat', rule: m.rule, date: m.date, time } + : { kind: 'repeat', rule: m.rule, date: m.date }, + ); + if (out.length === MAX_CANDIDATES) break; + } + return out; +} + +/** 补全用的说法(已归一化)及优先级。 */ +function completionPhrases(today: PlainDate): Array<[string, number]> { + const generic = [ + '每天', + '每周', + '每个工作日', + '每月', + '每年', + 'every day', + 'daily', + 'every week', + 'weekly', + 'every weekday', + 'weekdays', + 'every month', + 'monthly', + 'every year', + 'yearly', + ]; + const specific = [ + '每两周', + '每周末', + ...ZH_WEEKDAYS.map((chars) => `每周${chars.split('|')[0]}`), + `每月${today.day}号`, + '每月最后一天', + 'every other week', + 'every weekend', + ...EN_WEEKDAYS.map((names) => `every ${names[0]}`), + 'every month on the last day', + ]; + return [ + ...generic.map((p): [string, number] => [p, 0]), + ...specific.map((p): [string, number] => [p, 1]), + ]; +} + +// ─── 规则与第一次出现日 ──────────────────────────────────────────────── + +function resolve(spec: RepeatSpec, today: PlainDate): { rule: RepeatRule; date: string } { + const sorted = (days: number[]) => [...new Set(days)].sort((a, b) => a - b); + // 键序同规范形:unit, interval, weekdays?, monthDays?, anchor + const rule: RepeatRule = { + unit: spec.unit, + interval: spec.interval, + ...(spec.weekdays ? { weekdays: sorted(spec.weekdays) } : {}), + ...(spec.monthDays ? { monthDays: sorted(spec.monthDays) } : {}), + anchor: spec.anchor, + }; + return { rule, date: firstOccurrence(spec, today).toString() }; +} + +/** 今天或之后第一个命中规则日子的日期;没有日子集合时即今天。 */ +function firstOccurrence(spec: RepeatSpec, today: PlainDate): PlainDate { + if (spec.weekdays) { + const days = new Set(spec.weekdays); + let date = today; + while (!days.has(date.dayOfWeek % 7)) date = date.add({ days: 1 }); + return date; + } + if (spec.monthDays) { + for (let month = today.toPlainYearMonth(); ; month = month.add({ months: 1 })) { + const dates = spec.monthDays + .map((day) => month.toPlainDate({ day: Math.min(day, month.daysInMonth) })) + .filter((date) => Temporal.PlainDate.compare(date, today) >= 0) + .sort(Temporal.PlainDate.compare); + if (dates.length > 0) return dates[0]; + } + } + if (spec.yearDate) { + const { month, day } = spec.yearDate; + for (let year = today.year; ; year++) { + const ym = Temporal.PlainYearMonth.from({ year, month }); + const date = ym.toPlainDate({ day: Math.min(day, ym.daysInMonth) }); + if (Temporal.PlainDate.compare(date, today) >= 0) return date; + } + } + return today; +} + +// ─── 解析 ───────────────────────────────────────────────────────────── + +function parseSpec(text: string): RepeatSpec | null { + const spec = /[㐀-鿿]/.test(text) ? parseZh(text) : parseEn(text); + if (!spec || spec.interval < 1 || spec.interval > MAX_INTERVAL) return null; + return spec; +} + +/** 中文:[完成后][每|每隔][N][个](天|周…|工作日|周末|月…|年…);单说「工作日」也算。 */ +function parseZh(text: string): RepeatSpec | null { + if (text === '工作日') + return { unit: 'week', interval: 1, weekdays: WORKDAYS, anchor: 'scheduled' }; + const head = new RegExp(`^(?:(完成后)(?:每隔|每)?|每隔|每)(\\d{1,4}|${NUM})?个?(.+)$`).exec(text); + if (!head) return null; + const anchor: RepeatAnchor = head[1] ? 'completion' : 'scheduled'; + const interval = head[2] ? toNumber(head[2]) : 1; + if (interval === null) return null; + const body = head[3]; + const spec = (unit: RepeatUnit, extra: Partial = {}): RepeatSpec => ({ + unit, + interval, + anchor, + ...extra, + }); + + if (body === '天' || body === '日') return spec('day'); + if (body === '工作日' || body === '周末') { + if (head[2]) return null; // 「每两个工作日」不成立 + return spec('week', { weekdays: body === '工作日' ? WORKDAYS : WEEKEND }); + } + let m = /^(?:周|星期|礼拜)(.*)$/.exec(body); + if (m) { + if (!m[1]) return spec('week'); + const weekdays = zhWeekdays(m[1]); + return weekdays ? spec('week', { weekdays }) : null; + } + m = /^月(?:的)?(.*)$/.exec(body); + if (m) { + if (!m[1]) return spec('month'); + const monthDays = zhMonthDays(m[1]); + return monthDays ? spec('month', { monthDays }) : null; + } + m = /^年(?:的)?(.*)$/.exec(body); + if (m) { + if (!m[1]) return spec('year'); + const date = new RegExp(`^(${NUM})月(${NUM})[日号]?$`).exec(m[1]); + const month = date && toNumber(date[1]); + const day = date && toNumber(date[2]); + if (!month || !day || !validMonthDay(month, day)) return null; + return spec('year', { yearDate: { month, day } }); + } + return null; +} + +/** 「一三五」「一和周三」「日」→ 0–6(0 = 周日)。 */ +function zhWeekdays(text: string): number[] | null { + const chars = text.replace(/和|与|及|跟|周|星期|礼拜/g, ''); + if (!chars) return null; + const out: number[] = []; + for (const char of chars) { + const index = ZH_WEEKDAYS.findIndex((names) => names.split('|').includes(char)); + if (index < 0) return null; + out.push((index + 1) % 7); + } + return out; +} + +/** 「15号」「1号和15号」「1 15号」「最后一天」→ 1–31(31 = 月末)。 */ +function zhMonthDays(text: string): number[] | null { + const parts = text + .replace(/最后一天|月底|月末/g, ' 31 ') + .split(/[和与及\s]+/) + .filter(Boolean); + if (parts.length === 0) return null; + const out: number[] = []; + for (const part of parts) { + const day = toNumber(part.replace(/[号日]$/, '')); + if (day === null || day < 1 || day > 31) return null; + out.push(day); + } + return out; +} + +const EN_UNIT_WORDS: Record = { + day: ['day', 'days'], + week: ['week', 'weeks', 'wk', 'wks'], + month: ['month', 'months', 'mo', 'mos'], + year: ['year', 'years', 'yr', 'yrs'], +}; +const EN_ADVERBS: Record = { + daily: 'day', + weekly: 'week', + monthly: 'month', + yearly: 'year', + annually: 'year', +}; + +const EN_UNIT_PATTERN = Object.values(EN_UNIT_WORDS).flat().join('|'); + +function enUnit(word: string): RepeatUnit { + return (Object.keys(EN_UNIT_WORDS) as RepeatUnit[]).find((u) => EN_UNIT_WORDS[u].includes(word))!; +} + +function enNumber(word: string | undefined): number | null { + if (word === undefined) return 1; + if (/^\d+$/.test(word)) return Number(word); + if (word === 'other') return 2; + return EN_NUMBERS[word] ?? null; +} + +/** + * 英文:daily / weekly…[on …]、every|each [N|other] unit [on …]、every weekday / + * weekend、every mon, wed and fri、end of every month;末尾 after completion + * 改锚点(此时也接受不带 every 的「3 days after completion」)。 + */ +function parseEn(text: string): RepeatSpec | null { + let anchor: RepeatAnchor = 'scheduled'; + let s = text; + const after = /\s+after\s+(?:completion|completing|done|finishing)$/.exec(s); + if (after) { + anchor = 'completion'; + s = s.slice(0, after.index); + } + + if (s === 'weekdays') return { unit: 'week', interval: 1, weekdays: WORKDAYS, anchor }; + if (/^end of (?:every|each) month$/.test(s)) + return { unit: 'month', interval: 1, monthDays: [31], anchor }; + + let m = /^(daily|weekly|monthly|yearly|annually)(?:\s+on\s+(.+))?$/.exec(s); + if (m) return withQualifier(EN_ADVERBS[m[1]], 1, m[2], anchor); + + m = /^(?:every|each)\s+(.+)$/.exec(s); + const body = m ? m[1] : anchor === 'completion' ? s : null; + if (body === null) return null; + + if (m && /^(?:weekday|weekdays|workday|workdays)$/.test(body)) + return { unit: 'week', interval: 1, weekdays: WORKDAYS, anchor }; + if (m && /^(?:weekend|weekends)$/.test(body)) + return { unit: 'week', interval: 1, weekdays: WEEKEND, anchor }; + const weekdays = m ? enWeekdays(body) : null; + if (weekdays) return { unit: 'week', interval: 1, weekdays, anchor }; + + const unit = new RegExp( + `^(?:(\\d{1,4}|[a-z]+)\\s+)?(${EN_UNIT_PATTERN})(?:\\s+on\\s+(.+))?$`, + ).exec(body); + const interval = unit && enNumber(unit[1]); + if (!unit || interval === null) return null; + return withQualifier(enUnit(unit[2]), interval, unit[3], anchor); +} + +/** 「on …」限定:week 为星期几,month 为几号,year 为月日;day 不接受。 */ +function withQualifier( + unit: RepeatUnit, + interval: number, + qualifier: string | undefined, + anchor: RepeatAnchor, +): RepeatSpec | null { + const spec: RepeatSpec = { unit, interval, anchor }; + if (!qualifier) return spec; + if (unit === 'week') { + const weekdays = enWeekdays(qualifier); + return weekdays ? { ...spec, weekdays } : null; + } + if (unit === 'month') { + const monthDays = enMonthDays(qualifier); + return monthDays ? { ...spec, monthDays } : null; + } + if (unit === 'year') { + const yearDate = enMonthDay(qualifier); + return yearDate ? { ...spec, yearDate } : null; + } + return null; +} + +const LIST_SEPARATOR = /\s*(?:,|&|\/|\band\b)\s*|\s+/; + +/** 「mon, wed and fri」「tuesdays」→ 0–6。 */ +function enWeekdays(text: string): number[] | null { + const words = text.split(LIST_SEPARATOR).filter(Boolean); + if (words.length === 0) return null; + const out: number[] = []; + for (const word of words) { + const name = word.replace(/s$/, ''); + const index = EN_WEEKDAYS.findIndex((names) => names.includes(word) || names.includes(name)); + if (index < 0) return null; + out.push((index + 1) % 7); + } + return out; +} + +/** 「the 1st and 15th」「15」「day 1 and 15」「the last day」→ 1–31(31 = 月末)。 */ +function enMonthDays(text: string): number[] | null { + const words = text + .split(LIST_SEPARATOR) + .filter((w) => w && w !== 'the' && w !== 'day' && w !== 'days'); + if (words.length === 0) return null; + const out: number[] = []; + for (const word of words) { + if (word === 'last') { + out.push(31); + continue; + } + const m = /^(\d{1,2})(?:st|nd|rd|th)?$/.exec(word); + const day = m ? Number(m[1]) : NaN; + if (!(day >= 1 && day <= 31)) return null; + out.push(day); + } + return out; +} + +/** 「mar 8」「march 8th」「8 march」「the 8th of march」→ 月日。 */ +function enMonthDay(text: string): { month: number; day: number } | null { + const m = + /^([a-z]+)\s+(\d{1,2})(?:st|nd|rd|th)?$/.exec(text) ?? + /^(?:the\s+)?(\d{1,2})(?:st|nd|rd|th)?\s+(?:of\s+)?([a-z]+)$/.exec(text); + if (!m) return null; + const [name, dayText] = /^\d/.test(m[1]) ? [m[2], m[1]] : [m[1], m[2]]; + const index = EN_MONTHS.findIndex( + (full) => full === name || (name.length >= 3 && full.startsWith(name)), + ); + const day = Number(dayText); + if (index < 0 || !validMonthDay(index + 1, day)) return null; + return { month: index + 1, day }; +} + +/** 月日在某个年份存在(2/29 算存在)。 */ +function validMonthDay(month: number, day: number): boolean { + return exactDate(2028, month, day) !== null; +} diff --git a/packages/shared/src/when-query.ts b/packages/shared/src/when-query.ts index 74603cea..d6053361 100644 --- a/packages/shared/src/when-query.ts +++ b/packages/shared/src/when-query.ts @@ -1,5 +1,20 @@ import { Temporal } from '@js-temporal/polyfill'; +import { + EN_MONTHS, + EN_NUMBERS, + EN_WEEKDAYS, + NUM, + PARTIAL_TIME_TAIL, + ZH_NUM, + ZH_WEEKDAYS, + exactDate, + extractTime, + normalize, + toNumber, +} from './query-lexicon'; +import { parseRepeat, type RepeatCandidate } from './repeat-query'; + /** * When 选择器的自然语言日期输入(`.scratch/when-natural-input`):把输入框 * 里的文本解析为有序候选。纯函数,只在 date key 上计算(ADR 0013),today / @@ -14,10 +29,15 @@ export interface WhenQueryOptions { weekStartsOn: 0 | 1; /** 截止日期没有 Someday。 */ allowSomeday: boolean; + /** 单个实体的计划日期入口才识别重复说法(`repeat-query`)。 */ + allowRepeat?: boolean; } export type WhenCandidate = - { kind: 'date'; date: string; time?: string } | { kind: 'someday' } | { kind: 'clear' }; + | { kind: 'date'; date: string; time?: string } + | { kind: 'someday' } + | { kind: 'clear' } + | RepeatCandidate; const MAX_CANDIDATES = 6; @@ -30,6 +50,15 @@ interface Match { } export function parseWhenQuery(query: string, options: WhenQueryOptions): WhenCandidate[] { + if (!options.allowRepeat) return parseDateQuery(query, options); + // 重复说法(完全匹配或以「每」「every」等开头)只出重复候选;否则重复补全排在日期之后 + const repeat = parseRepeat(query, options); + if (repeat.exclusive) return repeat.candidates; + const dates = parseDateQuery(query, options); + return [...dates, ...repeat.candidates].slice(0, MAX_CANDIDATES); +} + +function parseDateQuery(query: string, options: WhenQueryOptions): WhenCandidate[] { const text = normalize(query); if (!text) return []; @@ -81,175 +110,6 @@ function rank(matches: Match[], allowSomeday: boolean, time: string | undefined) return out; } -// ─── 归一化 ──────────────────────────────────────────────────────────── - -const CJK = '\\u3400-\\u9fff'; - -function normalize(query: string): string { - return query - .normalize('NFKC') - .toLowerCase() - .replace(/[,。、]/g, ' ') - .replace(/\s+/g, ' ') - .trim() - .replace(new RegExp(`(?<=[${CJK}])\\s+|\\s+(?=[${CJK}])`, 'g'), ''); -} - -// ─── 数字 ───────────────────────────────────────────────────────────── - -const ZH_DIGITS: Record = { - 零: 0, - 〇: 0, - 一: 1, - 二: 2, - 两: 2, - 三: 3, - 四: 4, - 五: 5, - 六: 6, - 七: 7, - 八: 8, - 九: 9, -}; -const ZH_NUM = '[零〇一二两三四五六七八九十廿卅]+'; -const NUM = `(?:\\d{1,2}|${ZH_NUM})`; - -/** 阿拉伯数字或一到九十九的中文数字(含「两」「廿」「卅」)。 */ -function toNumber(text: string): number | null { - if (/^\d+$/.test(text)) return Number(text); - if (text.length === 1 && text in ZH_DIGITS) return ZH_DIGITS[text]; - const m = /^([一二三四五六七八九]?)([十廿卅])([一二三四五六七八九]?)$/.exec(text); - if (!m) return null; - const ones = m[3] ? ZH_DIGITS[m[3]] : 0; - if (m[2] === '十') return (m[1] ? ZH_DIGITS[m[1]] : 1) * 10 + ones; - if (m[1]) return null; // 「二廿」不成立 - return (m[2] === '廿' ? 20 : 30) + ones; -} - -const EN_NUMBERS: Record = { - a: 1, - an: 1, - one: 1, - two: 2, - three: 3, - four: 4, - five: 5, - six: 6, - seven: 7, - eight: 8, - nine: 9, - ten: 10, - eleven: 11, - twelve: 12, -}; - -// ─── 时刻 ───────────────────────────────────────────────────────────── - -type Period = 'am' | 'pm' | 'noon'; - -interface TimeResult { - rest: string; - time?: string; - impliesToday?: boolean; -} - -/** 正在输入、尚不完整的时刻尾巴:`at`、`9`、`9:`、`9:3`。 */ -const PARTIAL_TIME_TAIL = /\s*(?:(?:\bat\s+|@\s*)?\d{1,2}(?::\d?)?|\bat|@)$/; - -const ZH_PERIODS: Record = { - 凌晨: { period: 'am', standalone: '' }, - 早上: { period: 'am', standalone: '09:00' }, - 早晨: { period: 'am', standalone: '09:00' }, - 上午: { period: 'am', standalone: '09:00' }, - 中午: { period: 'noon', standalone: '12:00' }, - 下午: { period: 'pm', standalone: '14:00' }, - 傍晚: { period: 'pm', standalone: '19:00' }, - 晚上: { period: 'pm', standalone: '19:00' }, - 今晚: { period: 'pm', standalone: '20:00', today: true }, -}; -const EN_PERIODS: Record = { - morning: { period: 'am', standalone: '09:00' }, - afternoon: { period: 'pm', standalone: '14:00' }, - evening: { period: 'pm', standalone: '19:00' }, - tonight: { period: 'pm', standalone: '20:00', today: true }, -}; - -function hhmm(hour: number, minute: number): string { - return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`; -} - -/** 按 am / pm 修正小时;不合法返回 null。 */ -function resolveHour(hour: number, minute: number, period: Period | undefined): string | null { - if (minute < 0 || minute > 59) return null; - if (!period) return hour <= 23 ? hhmm(hour, minute) : null; - if (hour < 1 || hour > 12) return period === 'am' && hour === 0 ? hhmm(0, minute) : null; - if (period === 'am') return hhmm(hour % 12, minute); - if (period === 'noon') return hhmm(hour <= 2 ? hour + 12 : hour, minute); - return hhmm(hour === 12 ? 12 : hour + 12, minute); -} - -function extractTime(text: string): TimeResult | 'invalid' { - const cut = (m: RegExpExecArray) => - (text.slice(0, m.index) + ' ' + text.slice(m.index + m[0].length)).replace(/\s+/g, ' ').trim(); - const periodNames = Object.keys(ZH_PERIODS).join('|'); - - // 中文:[时段]N点[半|一刻|三刻|M分] - const zh = new RegExp(`(${periodNames})?(${NUM})[点點时](半|一刻|三刻|(${NUM})分?)?`).exec(text); - if (zh) { - const hour = toNumber(zh[2]); - const minute = - zh[3] === '半' - ? 30 - : zh[3] === '一刻' - ? 15 - : zh[3] === '三刻' - ? 45 - : zh[4] - ? toNumber(zh[4]) - : 0; - const info = zh[1] ? ZH_PERIODS[zh[1]] : undefined; - if (hour === null || minute === null) return 'invalid'; - const time = resolveHour(hour, minute, info?.period); - return time ? { rest: cut(zh), time, impliesToday: info?.today } : 'invalid'; - } - - // 英文 / 通用:9am、9:30pm、at 9、21:00 - const enPeriod = new RegExp(`\\b(?:this\\s+)?(${Object.keys(EN_PERIODS).join('|')})\\b`).exec( - text, - ); - const numeric = - /(?:\bat\s+|@\s*)?\b(\d{1,2})(?::(\d{2}))?\s*(a\.?m\.?|p\.?m\.?|a|p)(?![a-z])/.exec(text) ?? - /(?:\bat\s+|@\s*)(\d{1,2})(?::(\d{2}))?\b/.exec(text) ?? - /\b(\d{1,2}):(\d{2})\b/.exec(text); - if (numeric) { - const suffix = numeric[3]?.[0]; - const fromWord = enPeriod ? EN_PERIODS[enPeriod[1]] : undefined; - const period: Period | undefined = - suffix === 'a' ? 'am' : suffix === 'p' ? 'pm' : fromWord?.period; - const time = resolveHour(Number(numeric[1]), Number(numeric[2] ?? 0), period); - if (!time) return 'invalid'; - let rest = cut(numeric); - if (enPeriod && !suffix) rest = rest.replace(enPeriod[0], ' ').replace(/\s+/g, ' ').trim(); - return { rest, time, impliesToday: fromWord?.today && !suffix }; - } - - const word = /\b(noon|midnight)\b/.exec(text); - if (word) return { rest: cut(word), time: word[1] === 'noon' ? '12:00' : '00:00' }; - if (enPeriod) { - const info = EN_PERIODS[enPeriod[1]]; - return { rest: cut(enPeriod), time: info.standalone, impliesToday: info.today }; - } - - const zhWord = new RegExp(`(午夜|${periodNames})`).exec(text); - if (zhWord) { - if (zhWord[1] === '午夜') return { rest: cut(zhWord), time: '00:00' }; - const info = ZH_PERIODS[zhWord[1]]; - if (info.standalone) - return { rest: cut(zhWord), time: info.standalone, impliesToday: info.today }; - } - return { rest: text }; -} - // ─── 日期 ───────────────────────────────────────────────────────────── class Context { @@ -296,39 +156,6 @@ class Context { } } -function exactDate(year: number, month: number, day: number): PlainDate | null { - try { - return Temporal.PlainDate.from({ year, month, day }, { overflow: 'reject' }); - } catch { - return null; - } -} - -const EN_WEEKDAYS: string[][] = [ - ['monday', 'mon'], - ['tuesday', 'tue', 'tues'], - ['wednesday', 'wed'], - ['thursday', 'thu', 'thur', 'thurs'], - ['friday', 'fri'], - ['saturday', 'sat'], - ['sunday', 'sun'], -]; -const ZH_WEEKDAYS = ['一', '二', '三', '四', '五', '六', '日|天']; -const EN_MONTHS = [ - 'january', - 'february', - 'march', - 'april', - 'may', - 'june', - 'july', - 'august', - 'september', - 'october', - 'november', - 'december', -]; - /** 关键词短语表:别名 → 日期。按前缀匹配实现增量补全。 */ function phrases(ctx: Context): Array<[aliases: string[], value: () => Match['value']]> { const { today } = ctx; diff --git a/packages/ui/src/components/keyboard/KeyboardFieldPicker.tsx b/packages/ui/src/components/keyboard/KeyboardFieldPicker.tsx index 39ba9a62..7f165290 100644 --- a/packages/ui/src/components/keyboard/KeyboardFieldPicker.tsx +++ b/packages/ui/src/components/keyboard/KeyboardFieldPicker.tsx @@ -129,6 +129,7 @@ export function KeyboardFieldPicker({ kind, ids, onClose }: Props) { onPatch={patch} onClose={onClose} showReminder={single?.kind === 'task' && getClientKind() !== 'web'} + allowRepeat={single !== null} /> ); case 'deadline': diff --git a/packages/ui/src/components/project/ProjectContextMenu.tsx b/packages/ui/src/components/project/ProjectContextMenu.tsx index d42b1ef3..32fef61f 100644 --- a/packages/ui/src/components/project/ProjectContextMenu.tsx +++ b/packages/ui/src/components/project/ProjectContextMenu.tsx @@ -243,7 +243,7 @@ function PickerContent({ ); } if (kind === 'scheduled') { - return ; + return ; } if (kind === 'repeat') { return ; diff --git a/packages/ui/src/components/project/ProjectMetaRow.tsx b/packages/ui/src/components/project/ProjectMetaRow.tsx index 6dfe0725..7671b7bb 100644 --- a/packages/ui/src/components/project/ProjectMetaRow.tsx +++ b/packages/ui/src/components/project/ProjectMetaRow.tsx @@ -95,7 +95,9 @@ export function ProjectMetaRow({ project }: Props) { ) } > - {(close) => } + {(close) => ( + + )} ) : null} diff --git a/packages/ui/src/components/task/SwipeWhenPicker.tsx b/packages/ui/src/components/task/SwipeWhenPicker.tsx index a3d0a24a..2a0acb53 100644 --- a/packages/ui/src/components/task/SwipeWhenPicker.tsx +++ b/packages/ui/src/components/task/SwipeWhenPicker.tsx @@ -37,6 +37,7 @@ export function SwipeWhenPicker() { onPatch={patch} onClose={close} showReminder={getClientKind() !== 'web'} + allowRepeat /> )} diff --git a/packages/ui/src/components/task/TaskContextMenu.tsx b/packages/ui/src/components/task/TaskContextMenu.tsx index a9b396b6..bfa36ff8 100644 --- a/packages/ui/src/components/task/TaskContextMenu.tsx +++ b/packages/ui/src/components/task/TaskContextMenu.tsx @@ -350,6 +350,7 @@ export function TaskContextMenu({ task, current, children, variant = 'default' } onPatch={patch} onClose={() => setActivePicker(null)} showReminder={!group && getClientKind() !== 'web'} + allowRepeat={!group} /> )} {activePicker === 'repeat' && } diff --git a/packages/ui/src/components/task/TaskRowExpanded.tsx b/packages/ui/src/components/task/TaskRowExpanded.tsx index 2ff5d8cb..58ca58ad 100644 --- a/packages/ui/src/components/task/TaskRowExpanded.tsx +++ b/packages/ui/src/components/task/TaskRowExpanded.tsx @@ -130,6 +130,7 @@ export function TaskRowExpanded({ task, current }: Props) { onPatch={patch} onClose={close} showReminder={getClientKind() !== 'web'} + allowRepeat /> )} @@ -166,6 +167,7 @@ export function TaskRowExpanded({ task, current }: Props) { onPatch={patch} onClose={close} showReminder={getClientKind() !== 'web'} + allowRepeat /> )} diff --git a/packages/ui/src/components/task/fields/RepeatRuleField.test.tsx b/packages/ui/src/components/task/fields/RepeatRuleField.test.tsx index c7ca3e96..98c29d9f 100644 --- a/packages/ui/src/components/task/fields/RepeatRuleField.test.tsx +++ b/packages/ui/src/components/task/fields/RepeatRuleField.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; import React from 'react'; @@ -119,6 +119,40 @@ describe('RepeatRuleField — 独立重复规则编辑字段(recurring-tasks s }); }); + it('按月:日子网格多选写入 monthDays,预览跟随;离开 month 剥离 monthDays', async () => { + const user = userEvent.setup(); + const { onPatch } = renderField({ + start: Start.ANYTIME, + scheduledDate: '2026-02-05', + repeatRule: { unit: 'month', interval: 1, anchor: 'scheduled' }, + }); + const grid = screen.getByRole('group', { name: /Repeat days of the month|每月的几号/ }); + expect(grid.querySelectorAll('button')).toHaveLength(31); + expect(screen.getByText(/With no days selected|未选日子时按计划日期当天/)).toBeInTheDocument(); + + await user.click(within(grid).getByRole('button', { name: '15' })); + expect(onPatch).toHaveBeenLastCalledWith({ + repeatRule: { unit: 'month', interval: 1, monthDays: [15], anchor: 'scheduled' }, + }); + await user.click(within(grid).getByRole('button', { name: '1' })); + expect(onPatch).toHaveBeenLastCalledWith({ + repeatRule: { unit: 'month', interval: 1, monthDays: [1, 15], anchor: 'scheduled' }, + }); + // 2026-02-05 之后的 [1,15] → 02-15 + expect(screen.getByText(/Next:|下次:/).textContent).toMatch(/15/); + + await user.click(within(grid).getByRole('button', { name: '31' })); + expect(screen.getByText(/Days 29–31|29–31 号/)).toBeInTheDocument(); + + await user.click(screen.getByRole('radio', { name: /^(weeks|周)$/ })); + expect(onPatch).toHaveBeenLastCalledWith({ + repeatRule: { unit: 'week', interval: 1, anchor: 'scheduled' }, + }); + expect( + screen.queryByRole('group', { name: /Repeat days of the month|每月的几号/ }), + ).not.toBeInTheDocument(); + }); + it('实时预览显示下一次出现日期(纯函数计算)', () => { renderField({ start: Start.ANYTIME, diff --git a/packages/ui/src/components/task/fields/RepeatRuleField.tsx b/packages/ui/src/components/task/fields/RepeatRuleField.tsx index 77534098..93127827 100644 --- a/packages/ui/src/components/task/fields/RepeatRuleField.tsx +++ b/packages/ui/src/components/task/fields/RepeatRuleField.tsx @@ -34,10 +34,11 @@ interface FieldProps { } const REPEAT_UNITS: RepeatUnit[] = ['day', 'week', 'month', 'year']; +const MONTH_DAYS = Array.from({ length: 31 }, (_, i) => i + 1); /** * 重复规则独立编辑字段(recurring-tasks spec):单位选择、「每 N」步进器、 - * 周模式星期按钮、「从完成日期算」锚点开关、可选 until 日期,以及「下次」 + * 周模式星期按钮、月模式日子网格、「从完成日期算」锚点开关、可选 until 日期,以及「下次」 * 实时预览(纯函数 nextOccurrenceDate 计算,保存前即可验证规则)。 * * 与 ScheduledDateField 平级的独立入口(不内嵌于计划 popover):入口仅在 @@ -78,11 +79,21 @@ export function RepeatRuleField({ current, onPatch }: FieldProps) { patchRule({ ...rule, weekdays: next }); }; + const toggleMonthDay = (day: number) => { + if (!rule) return; + const monthDays = rule.monthDays ?? []; + const next = monthDays.includes(day) + ? monthDays.filter((d) => d !== day) + : [...monthDays, day].sort((a, b) => a - b); + patchRule({ ...rule, monthDays: next }); + }; + const changeUnit = (unit: RepeatUnit) => { if (!rule || unit === rule.unit) return; - // 离开 week 单位时剥离 weekdays(数据层归一化也会剥,这里保持 UI 即时一致) + // 离开 week / month 单位时剥离 weekdays / monthDays(数据层归一化也会剥,这里保持 UI 即时一致) const next = { ...rule, unit }; if (unit !== 'week') delete next.weekdays; + if (unit !== 'month') delete next.monthDays; patchRule(next); }; @@ -107,6 +118,13 @@ export function RepeatRuleField({ current, onPatch }: FieldProps) { patchRule(next); }; + // 未选日子 = 按计划日期当天;选了 29–31 时提示短月落在月末 + const monthDaysHint = !rule?.monthDays?.length + ? t('task:repeatMonthDaysDefault') + : rule.monthDays.some((day) => day >= 29) + ? t('task:repeatMonthDaysClamp') + : null; + // 实时预览:anchor=completion 按「今天完成」预览;until 已过则显示终结 const previewDate = rule ? nextOccurrenceDate(rule, { @@ -224,6 +242,39 @@ export function RepeatRuleField({ current, onPatch }: FieldProps) { )} + {rule?.unit === 'month' && ( +
+
+ {MONTH_DAYS.map((day) => { + const active = rule.monthDays?.includes(day) ?? false; + return ( + + ); + })} +
+ {monthDaysHint && ( +

{monthDaysHint}

+ )} +
+ )} +