yiekheng b67d3c735e feat(recurrence): replace the long preset list with a guided cron flow
Per the user's ask: stop dumping 12 presets in front of the user. Walk
them through "pick a frequency, then configure it." Each choice
expands its config inline below the radio.

Picker (now 8 top-level choices):
  ○ Don't repeat                       (one-off)
  ○ Every N minutes                    → number input (1-59)
  ○ Every N hours                      → number input (1-23) at :MM
  ○ Every day at HH:MM                 (uses outer time picker)
  ○ Every week at HH:MM                → weekday chip multi-select
  ○ Every month at HH:MM               → day-of-month input (1-31)
  ○ Every year at HH:MM                → month select + day input
  ○ Custom cron expression…            → free-form textbox

Behaviour:
- Selecting a row reveals only that row's config; the others stay
  collapsed so the screen stays calm.
- HH:MM in every "at HH:MM" label tracks the outer time picker — change
  the time and every label updates instantly. Same for the cron
  expression the picker emits.
- Every config change recompiles to a single cron string and pushes a
  `{ kind: "cron", cron: "..." }` spec up to the parent. Empty weekday
  list yields null (config not yet valid).
- Editing an existing reminder calls `flowFromCron(rule, firstFire)`
  which reverse-engineers a flow state from the stored cron — including
  expanding `1-5` ranges into a weekday chip list — so the right radio
  is highlighted and config inputs are pre-populated.
- Anything not recognised by `flowFromCron` (legacy RRULE, hand-rolled
  cron) lands on "Custom cron expression…" with the literal expression
  in the textbox.

Helpers in `lib/recurrence.ts`:
  - `FreqChoice` ("none" | "minute" | "hour" | "day" | "week" | "month"
    | "year" | "cron") + `FlowState` interface with all config fields.
  - `freqChoices(firstFire)` → first-fire-aware label list for the radio.
  - `defaultFlowState(firstFire)` → seeds sensible defaults (today's
    weekday, day-of-month, month, etc.).
  - `flowToCron(flow, firstFire)` → cron string or null. Clamps
    out-of-range integers.
  - `flowFromCron(rule, firstFire)` → best-effort reverse mapping.
  - `isoWeekdayToCron(iso)` → maps ISO 1-7 (Mon..Sun) to cron 0-6
    (Sun..Sat).

Removed: the previous `presetToSpec` / `matchPreset` / `presetDescriptors`
+ `presetCron` family. They're superseded by the flow helpers.

Tests (+11 in recurrence.test.ts; total 139 web + 26 bot + 17 shared
= 182):
- freqChoices order and time-bearing labels
- flowToCron for every freq + config combination, including empty
  weekday list returning null
- clamp behaviour for out-of-range minute/month-day/month integers
- isoWeekdayToCron for Mon..Sun
- defaultFlowState seeded fields
- flowFromCron round-trips every flow output exactly
- BYDAY range expansion (1-5 → [1,2,3,4,5])
- unrecognised expressions land on the cron textbox
- buildRrule + specFromRrule still handle CRON: prefixed strings

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