yiekheng 63b88c69b4 feat(recurrence): cron-only Repeats picker
Per the user's ask: drop the friendly RRULE-based shortcuts (Daily /
Weekly / Custom… etc.) — every selectable preset is now a cron
expression. Schedules are stored in `reminders.rrule` with the
`CRON:` sentinel and dispatched via the existing cron-aware
`nextOccurrence` helper.

Picker
- "Don't repeat" stays at the top (one-off, no cron).
- 11 cron-flavoured presets, each with its underlying cron expression
  shown as the hint:
    Every minute               * * * * *
    Every 5 minutes            */5 * * * *
    Every 15 minutes           */15 * * * *
    Every 30 minutes           */30 * * * *
    Every hour at :MM          MM * * * *
    Every day at HH:MM         MM HH * * *
    Every weekday at HH:MM     MM HH * * 1-5
    Every weekend at HH:MM     MM HH * * 0,6
    Every <DOW> at HH:MM       MM HH * * <cron-dow>
    Every month on day D at HH:MM   MM HH D * *
    Every year on Mon D at HH:MM    MM HH D M *
- Labels are first-fire-aware: changing the time picker re-derives
  every "at HH:MM" label and the preset's canonical cron string.
- "Custom cron expression…" reveals a free-form text input for
  anything not covered by the presets.
- Removed: the old "Custom" RRULE detail panel (frequency dropdown,
  weekday picker, monthday input, end-condition picker).

Storage
- `presetToSpec("none")` → kind:"none". Every other preset →
  kind:"cron" with its canonical cron string.
- `matchPreset` compares the spec's cron expression against each
  preset's canonical cron for the current first-fire — falls back to
  "cron" (custom textbox) for anything else, including legacy RRULE
  reminders that haven't been re-saved yet. Existing RRULE reminders
  keep firing on the bot side (nextOccurrence still dispatches both).
- `presetCron(id, firstFire)` is a small pure helper; ISO weekday
  (1=Mon..7=Sun) maps to cron weekday (0=Sun..6=Sat).

Tests (+8 in recurrence.test.ts; 137 web + 26 bot + 17 shared = 180)
- presetToSpec emits the right cron for every recurring preset
  including Sunday → cron weekday 0.
- matchPreset round-trips through presetToSpec for every preset.
- matchPreset returns "cron" for arbitrary (non-preset) cron strings.
- presetDescriptors lists exactly the cron-only items in order with
  first-fire-aware labels ("Every weekday at 09:00", "Every Wed at
  09:00", "Every year on May 13 at 09:00", "Custom cron expression…").
- buildRrule produces CRON: prefixed strings for cron presets and
  null for "none".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:32:29 +08:00
..