From 991ff5fb229626dd23ed2f8fc8790d32975ea11f Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sun, 10 May 2026 10:18:39 +0800 Subject: [PATCH] feat(recurrence): redesign Repeats picker as a preset radio list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old picker was a row of 5 frequency pills (One-off / Daily / Weekly / Monthly / Yearly) followed by a separate detail panel — common cases needed several clicks (interval, weekday list, etc.) and the visual hierarchy didn't show what was selected at a glance. New design — a vertical radio list with seven first-fire-aware presets plus a Custom… expander: ○ Don't repeat (one-off) ○ Every day ○ Every weekday (Mon – Fri) ○ Every weekend (Sat – Sun) ○ Every week on Wed (matches start) ○ Every month on day 13 (matches start) ○ Every year on May 13 (matches start) ○ Custom… ▼ expands Custom… reveals the existing power-user controls (frequency dropdown, interval input, weekday picker, day-of-month, end-condition) without crowding the common path. Toggling between presets and custom is lossless — the spec is the source of truth. New helpers in `lib/recurrence.ts`: - `presetToSpec(id, firstFire)` — canonical RecurrenceSpec for each preset (round-trippable). - `matchPreset(spec, firstFire)` — reverse mapping; returns "custom" for anything that doesn't fit a shortcut, so the picker auto-flips into expanded mode for non-preset specs. - `presetDescriptors(firstFire)` — list of preset id/label/hint with first-fire-aware copy ("Every week on Wed", "May 13", etc). Wired into both: - reminder-wizard/when-form-client.tsx (creating) - reminder-edit/edit-when-form.tsx (editing a section in place) Tests (+4, 134 web + 26 bot = 160 total green): - recurrence.test.ts gains a "preset shortcuts" suite covering: * presetToSpec → canonical spec for each id * round-trip via matchPreset * matchPreset returns "custom" for non-shortcut specs (interval > 1, weekly Mon/Wed/Fri, end=after, monthly on a different day-of-month than the first fire) * presetDescriptors labels are first-fire-aware Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/web/src/components/recurrence-picker.tsx | 366 ++++++++++++++++++ .../reminder-edit/edit-when-form.tsx | 241 +----------- .../reminder-wizard/when-form-client.tsx | 274 +------------ apps/web/src/lib/recurrence.test.ts | 88 +++++ apps/web/src/lib/recurrence.ts | 127 ++++++ 5 files changed, 614 insertions(+), 482 deletions(-) create mode 100644 apps/web/src/components/recurrence-picker.tsx diff --git a/apps/web/src/components/recurrence-picker.tsx b/apps/web/src/components/recurrence-picker.tsx new file mode 100644 index 0000000..ec60418 --- /dev/null +++ b/apps/web/src/components/recurrence-picker.tsx @@ -0,0 +1,366 @@ +"use client"; + +import { useState } from "react"; +import { DateTime } from "luxon"; +import { + CheckIcon, + ChevronDownIcon, + ChevronUpIcon, + RepeatIcon, +} from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/utils"; +import { + WEEKDAY_LABELS, + matchPreset, + presetDescriptors, + presetToSpec, + type EndKind, + type PresetId, + type RecurrenceKind, + type RecurrenceSpec, +} from "@/lib/recurrence"; + +interface RecurrencePickerProps { + /** First fire of the reminder — drives preset labels (e.g. "Every week on Wed"). */ + firstFire: DateTime; + value: RecurrenceSpec; + onChange: (next: RecurrenceSpec) => void; +} + +const FREQ_UNIT: Record, string> = { + daily: "day", + weekly: "week", + monthly: "month", + yearly: "year", +}; + +/** + * Reminder repeat picker. + * + * ┌──────────────────────────────────────────────────────┐ + * │ ○ Don't repeat (one-off) │ + * │ ○ Every day │ + * │ ○ Every weekday Mon – Fri │ + * │ ○ Every weekend Sat – Sun │ + * │ ○ Every week on Wed Same weekday as start │ + * │ ○ Every month on day 13 … │ + * │ ○ Every year on May 13 │ + * │ ○ Custom… (expands the full power-user controls) │ + * └──────────────────────────────────────────────────────┘ + * + * Selecting a preset overwrites the spec to the canonical preset value. + * "Custom…" reveals interval / weekday picker / day-of-month / end + * controls so the user can build any RRULE we support. + */ +export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePickerProps) { + const activePreset = matchPreset(value, firstFire); + // The user can also expand the custom panel manually even when the + // current spec matches a preset — useful for "I want every 2 weeks + // on Mon, Wed". Default to expanded whenever the spec is custom. + const [forceCustomOpen, setForceCustomOpen] = useState(false); + const customExpanded = activePreset === "custom" || forceCustomOpen; + + function pickPreset(id: PresetId) { + if (id === "custom") { + setForceCustomOpen(true); + // If the spec is currently a preset, seed the custom panel with + // its canonical form so editing starts from a sensible baseline. + if (activePreset !== "custom") { + onChange(presetToSpec("weekly_same", firstFire)); + } + return; + } + setForceCustomOpen(false); + onChange(presetToSpec(id, firstFire)); + } + + return ( +
+ + +
+
    + {presetDescriptors(firstFire).map((p) => { + const selected = activePreset === p.id; + return ( +
  • + +
  • + ); + })} +
+ + {customExpanded && ( + + )} +
+
+ ); +} + +interface CustomPanelProps { + firstFire: DateTime; + value: RecurrenceSpec; + onChange: (next: RecurrenceSpec) => void; +} + +function CustomPanel({ firstFire, value, onChange }: CustomPanelProps) { + const kind = value.kind === "none" ? "weekly" : value.kind; + + function setKind(k: RecurrenceKind) { + if (k === "none") { + onChange(presetToSpec("none", firstFire)); + return; + } + onChange({ + ...value, + kind: k, + // Seed weeklyDays with the first-fire weekday when flipping into + // weekly so the spec is concrete. + weeklyDays: + k === "weekly" && value.weeklyDays.length === 0 + ? [firstFire.weekday] + : value.weeklyDays, + monthDay: + k === "monthly" && value.monthDay === undefined + ? firstFire.day + : value.monthDay, + }); + } + + function setInterval(n: number) { + const safe = Number.isFinite(n) && n >= 1 ? Math.floor(n) : 1; + onChange({ ...value, interval: safe }); + } + + function toggleWeekday(iso: number) { + const next = value.weeklyDays.includes(iso) + ? value.weeklyDays.filter((d) => d !== iso) + : [...value.weeklyDays, iso].sort((a, b) => a - b); + onChange({ ...value, weeklyDays: next }); + } + + function setMonthDay(n: number | "") { + if (n === "") { + onChange({ ...value, monthDay: undefined }); + return; + } + if (n >= 1 && n <= 31) onChange({ ...value, monthDay: n }); + } + + function setEndKind(k: EndKind) { + if (k === "never") onChange({ ...value, end: { kind: "never" } }); + else if (k === "after") + onChange({ + ...value, + end: { kind: "after", count: value.end.kind === "after" ? value.end.count : 10 }, + }); + else + onChange({ + ...value, + end: { kind: "on", until: value.end.kind === "on" ? value.end.until : "" }, + }); + } + + return ( +
+ {/* Frequency */} +
+ + +
+ + {/* Interval */} +
+ + setInterval(Number(e.target.value))} + className="h-8 w-20" + /> + + {FREQ_UNIT[kind]} + {value.interval === 1 ? "" : "s"} + +
+ + {/* Weekly day picker */} + {kind === "weekly" && ( +
+ +
+ {WEEKDAY_LABELS.map(({ iso, short }) => { + const active = value.weeklyDays.includes(iso); + return ( + + ); + })} +
+
+ )} + + {/* Monthly day-of-month */} + {kind === "monthly" && ( +
+ + { + const v = e.target.value; + if (v === "") setMonthDay(""); + else setMonthDay(Number(v)); + }} + placeholder={String(firstFire.day)} + className="h-8 w-24" + /> +

+ Months without this day skip naturally (e.g. 31st). +

+
+ )} + + {/* End condition */} +
+ +
+ {(["never", "after", "on"] as const).map((v) => { + const active = value.end.kind === v; + const label = v === "never" ? "Never" : v === "after" ? "After…" : "On date…"; + return ( + + ); + })} +
+ {value.end.kind === "after" && ( +
+ { + const n = Number(e.target.value); + onChange({ + ...value, + end: { kind: "after", count: Number.isFinite(n) && n >= 1 ? n : 1 }, + }); + }} + className="h-8 w-24" + /> + + occurrence{value.end.count === 1 ? "" : "s"} + +
+ )} + {value.end.kind === "on" && ( +
+ + onChange({ ...value, end: { kind: "on", until: e.target.value } }) + } + className="h-8 w-44" + /> +
+ )} +
+
+ ); +} diff --git a/apps/web/src/components/reminder-edit/edit-when-form.tsx b/apps/web/src/components/reminder-edit/edit-when-form.tsx index fba5972..78438d0 100644 --- a/apps/web/src/components/reminder-edit/edit-when-form.tsx +++ b/apps/web/src/components/reminder-edit/edit-when-form.tsx @@ -8,22 +8,18 @@ import { CalendarIcon, ClockIcon, Loader2Icon, - RepeatIcon, SaveIcon, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { cn } from "@/lib/utils"; import { splitDateTime, validateScheduledAt } from "@/lib/date-picker"; import { - WEEKDAY_LABELS, buildRrule, describeRecurrence, - type EndKind, - type RecurrenceKind, type RecurrenceSpec, } from "@/lib/recurrence"; +import { RecurrencePicker } from "@/components/recurrence-picker"; import { updateReminderAction } from "@/actions/reminders"; interface EditWhenFormProps { @@ -38,20 +34,6 @@ interface EditWhenFormProps { timezone: string; } -const KINDS: Array<{ value: RecurrenceKind; label: string }> = [ - { value: "none", label: "One-off" }, - { value: "daily", label: "Daily" }, - { value: "weekly", label: "Weekly" }, - { value: "monthly", label: "Monthly" }, - { value: "yearly", label: "Yearly" }, -]; -const FREQ_UNIT: Record, string> = { - daily: "day", - weekly: "week", - monthly: "month", - yearly: "year", -}; - export function EditWhenForm({ reminderId, accountId, @@ -68,45 +50,17 @@ export function EditWhenForm({ const [date, setDate] = useState(initial.date); const [time, setTime] = useState(initial.time); - const [kind, setKind] = useState(initialSpec.kind); - const [interval, setIntervalValue] = useState(initialSpec.interval); - const [weeklyDays, setWeeklyDays] = useState(initialSpec.weeklyDays); - const [monthDay, setMonthDay] = useState(initialSpec.monthDay ?? ""); - const [endKind, setEndKind] = useState(initialSpec.end.kind); - const [endCount, setEndCount] = useState( - initialSpec.end.kind === "after" ? initialSpec.end.count : 10, - ); - const [endUntil, setEndUntil] = useState( - initialSpec.end.kind === "on" ? initialSpec.end.until : "", - ); + const [spec, setSpec] = useState(initialSpec); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); - function toggleWeekday(iso: number) { - setWeeklyDays((prev) => - prev.includes(iso) ? prev.filter((d) => d !== iso) : [...prev, iso].sort((a, b) => a - b), - ); - } - - function buildSpec(firstFire: DateTime): RecurrenceSpec { - const safeMonthDay = - typeof monthDay === "number" && monthDay >= 1 && monthDay <= 31 - ? monthDay - : firstFire.day; - let end: RecurrenceSpec["end"] = { kind: "never" }; - if (endKind === "after" && endCount > 0) { - end = { kind: "after", count: Math.floor(endCount) }; - } else if (endKind === "on" && endUntil) { - end = { kind: "on", until: endUntil }; + const previewDt = (() => { + if (date && time) { + const d = DateTime.fromFormat(`${date} ${time}`, "yyyy-MM-dd HH:mm", { zone: timezone }); + if (d.isValid) return d; } - return { - kind, - interval: Math.max(1, Math.floor(interval || 1)), - weeklyDays, - monthDay: kind === "monthly" ? safeMonthDay : undefined, - end, - }; - } + return DateTime.fromISO(initialIso, { zone: timezone }); + })(); async function handleSave() { const v = validateScheduledAt(date, time, timezone, Date.now()); @@ -119,12 +73,11 @@ export function EditWhenForm({ setError(map[v.reason]); return; } - if (endKind === "on" && !endUntil) { + if (spec.end.kind === "on" && !spec.end.until) { setError("Pick the end date for this recurrence."); return; } const dt = v.dt; - const spec = buildSpec(dt); const rrule = buildRrule(spec, dt); setSubmitting(true); @@ -154,14 +107,7 @@ export function EditWhenForm({ } } - const previewDt = - date && time - ? DateTime.fromFormat(`${date} ${time}`, "yyyy-MM-dd HH:mm", { zone: timezone }) - : null; - const previewSentence = - previewDt && previewDt.isValid - ? describeRecurrence(buildSpec(previewDt), previewDt) - : null; + const previewSentence = describeRecurrence(spec, previewDt); return (
@@ -169,7 +115,7 @@ export function EditWhenForm({
-
- -
- {KINDS.map(({ value, label }) => { - const active = kind === value; - return ( - - ); - })} -
-
- - {kind !== "none" && ( -
-
- - { - const n = Number(e.target.value); - setIntervalValue(Number.isFinite(n) && n >= 1 ? n : 1); - setError(null); - }} - className="h-8 w-20" - /> - - {FREQ_UNIT[kind]} - {interval === 1 ? "" : "s"} - -
- - {kind === "weekly" && ( -
- -
- {WEEKDAY_LABELS.map(({ iso, short }) => { - const active = weeklyDays.includes(iso); - return ( - - ); - })} -
-
- )} - - {kind === "monthly" && ( -
- - { - const v = e.target.value; - if (v === "") setMonthDay(""); - else { - const n = Number(v); - if (Number.isFinite(n) && n >= 1 && n <= 31) setMonthDay(n); - } - setError(null); - }} - className="h-8 w-24" - /> -
- )} - -
- -
- {(["never", "after", "on"] as const).map((v) => { - const active = endKind === v; - const label = v === "never" ? "Never" : v === "after" ? "After…" : "On date…"; - return ( - - ); - })} -
- {endKind === "after" && ( -
- { - const n = Number(e.target.value); - setEndCount(Number.isFinite(n) && n >= 1 ? n : 1); - setError(null); - }} - className="h-8 w-24" - /> - - occurrence{endCount === 1 ? "" : "s"} - -
- )} - {endKind === "on" && ( -
- { - setEndUntil(e.target.value); - setError(null); - }} - className="h-8 w-44" - /> -
- )} -
-
- )} + {previewSentence && (

diff --git a/apps/web/src/components/reminder-wizard/when-form-client.tsx b/apps/web/src/components/reminder-wizard/when-form-client.tsx index 1702479..e51104c 100644 --- a/apps/web/src/components/reminder-wizard/when-form-client.tsx +++ b/apps/web/src/components/reminder-wizard/when-form-client.tsx @@ -3,20 +3,18 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { DateTime } from "luxon"; -import { CalendarIcon, ClockIcon, AlertCircleIcon, RepeatIcon } from "lucide-react"; +import { CalendarIcon, ClockIcon, AlertCircleIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { cn } from "@/lib/utils"; import { - WEEKDAY_LABELS, buildRrule, describeRecurrence, - type RecurrenceKind, + DEFAULT_RECURRENCE, type RecurrenceSpec, - type EndKind, } from "@/lib/recurrence"; import { splitDateTime, validateScheduledAt } from "@/lib/date-picker"; +import { RecurrencePicker } from "@/components/recurrence-picker"; interface PassThroughParams { text?: string; @@ -34,21 +32,6 @@ interface WhenFormClientProps { passThroughParams: PassThroughParams; } -const KINDS: Array<{ value: RecurrenceKind; label: string }> = [ - { value: "none", label: "One-off" }, - { value: "daily", label: "Daily" }, - { value: "weekly", label: "Weekly" }, - { value: "monthly", label: "Monthly" }, - { value: "yearly", label: "Yearly" }, -]; - -const FREQ_UNIT: Record, string> = { - daily: "day", - weekly: "week", - monthly: "month", - yearly: "year", -}; - export function WhenFormClient({ accountId, groupIds, @@ -62,75 +45,46 @@ export function WhenFormClient({ const [date, setDate] = useState(initial.date); const [time, setTime] = useState(initial.time); - const [kind, setKind] = useState(initialSpec?.kind ?? "none"); - const [interval, setIntervalValue] = useState(initialSpec?.interval ?? 1); - const [weeklyDays, setWeeklyDays] = useState(initialSpec?.weeklyDays ?? []); - const [monthDay, setMonthDay] = useState( - initialSpec?.monthDay ?? "", - ); - const [endKind, setEndKind] = useState(initialSpec?.end.kind ?? "never"); - const [endCount, setEndCount] = useState( - initialSpec?.end.kind === "after" ? initialSpec.end.count : 10, - ); - const [endUntil, setEndUntil] = useState( - initialSpec?.end.kind === "on" ? initialSpec.end.until : "", - ); + const [spec, setSpec] = useState(initialSpec ?? DEFAULT_RECURRENCE); const [error, setError] = useState(null); - function toggleWeekday(iso: number) { - setWeeklyDays((prev) => - prev.includes(iso) ? prev.filter((d) => d !== iso) : [...prev, iso].sort((a, b) => a - b), - ); - } - - function buildSpec(firstFire: DateTime): RecurrenceSpec { - const safeMonthDay = - typeof monthDay === "number" && monthDay >= 1 && monthDay <= 31 - ? monthDay - : firstFire.day; - let end: RecurrenceSpec["end"] = { kind: "never" }; - if (endKind === "after" && endCount > 0) { - end = { kind: "after", count: Math.floor(endCount) }; - } else if (endKind === "on" && endUntil) { - end = { kind: "on", until: endUntil }; + // The first-fire DateTime drives preset labels in the picker. Fall + // back to the default ISO if the inputs aren't a valid pair yet. + const previewDt = (() => { + if (date && time) { + const d = DateTime.fromFormat(`${date} ${time}`, "yyyy-MM-dd HH:mm", { zone: timezone }); + if (d.isValid) return d; } - return { - kind, - interval: Math.max(1, Math.floor(interval || 1)), - weeklyDays, - monthDay: kind === "monthly" ? safeMonthDay : undefined, - end, - }; - } + return DateTime.fromISO(initialDefaultIso, { zone: timezone }); + })(); function handleContinue() { const v = validateScheduledAt(date, time, timezone, Date.now()); if (!v.ok) { - const map: Record = { + const map = { missing: "Pick both a date and a time.", invalid: "Invalid date or time.", past: "The first occurrence is in the past. Pick a future date and time.", - }; + } as const; setError(map[v.reason]); return; } const dt = v.dt; - if (endKind === "on" && !endUntil) { + if (spec.end.kind === "on" && !spec.end.until) { setError("Pick the end date for this recurrence."); return; } - if (endKind === "on" && endUntil) { - const until = DateTime.fromISO(endUntil, { zone: timezone }); + if (spec.end.kind === "on" && spec.end.until) { + const until = DateTime.fromISO(spec.end.until, { zone: timezone }); if (until.isValid && until.toMillis() <= dt.toMillis()) { setError("The end date must be after the first fire."); return; } } - if (endKind === "after" && (!Number.isFinite(endCount) || endCount < 1)) { + if (spec.end.kind === "after" && (!Number.isFinite(spec.end.count) || spec.end.count < 1)) { setError("Number of occurrences must be at least 1."); return; } - const spec = buildSpec(dt); const rrule = buildRrule(spec, dt); const scheduledAt = dt.toISO()!; const sp = new URLSearchParams({ @@ -148,15 +102,7 @@ export function WhenFormClient({ router.push(`/reminders/new?${sp.toString()}` as any); } - // Live preview text — uses the parsed first-fire if valid, else the date input alone. - const previewDt = (() => { - if (!date || !time) return null; - const d = DateTime.fromFormat(`${date} ${time}`, "yyyy-MM-dd HH:mm", { zone: timezone }); - return d.isValid ? d : null; - })(); - const previewSpec = previewDt ? buildSpec(previewDt) : null; - const previewSentence = - previewDt && previewSpec ? describeRecurrence(previewSpec, previewDt) : null; + const previewSentence = describeRecurrence(spec, previewDt); return (

@@ -165,7 +111,7 @@ export function WhenFormClient({
- {/* Frequency */} -
- -
- {KINDS.map(({ value, label }) => { - const active = kind === value; - return ( - - ); - })} -
-
+ - {/* Recurrence detail — interval, weekdays, monthday, end */} - {kind !== "none" && ( -
- {/* Interval */} -
- - { - const n = Number(e.target.value); - setIntervalValue(Number.isFinite(n) && n >= 1 ? n : 1); - setError(null); - }} - className="h-8 w-20" - /> - - {FREQ_UNIT[kind]} - {interval === 1 ? "" : "s"} - -
- - {/* Weekly days */} - {kind === "weekly" && ( -
- -
- {WEEKDAY_LABELS.map(({ iso, short }) => { - const active = weeklyDays.includes(iso); - return ( - - ); - })} -
-

- Leave empty to use the start date's weekday only. -

-
- )} - - {/* Monthly day-of-month */} - {kind === "monthly" && ( -
- - { - const v = e.target.value; - if (v === "") { - setMonthDay(""); - } else { - const n = Number(v); - if (Number.isFinite(n) && n >= 1 && n <= 31) setMonthDay(n); - } - setError(null); - }} - placeholder={String((previewDt ?? DateTime.now()).day)} - className="h-8 w-24" - /> -

- Months without this day skip naturally (e.g. 31st). -

-
- )} - - {/* End condition */} -
- -
- {(["never", "after", "on"] as const).map((v) => { - const active = endKind === v; - const label = v === "never" ? "Never" : v === "after" ? "After…" : "On date…"; - return ( - - ); - })} -
- {endKind === "after" && ( -
- { - const n = Number(e.target.value); - setEndCount(Number.isFinite(n) && n >= 1 ? n : 1); - setError(null); - }} - className="h-8 w-24" - /> - - occurrence{endCount === 1 ? "" : "s"} - -
- )} - {endKind === "on" && ( -
- { - setEndUntil(e.target.value); - setError(null); - }} - className="h-8 w-44" - /> -
- )} -
-
- )} - - {/* Live preview */} {previewSentence && (

{previewSentence} diff --git a/apps/web/src/lib/recurrence.test.ts b/apps/web/src/lib/recurrence.test.ts index 13910b5..ba45ca9 100644 --- a/apps/web/src/lib/recurrence.test.ts +++ b/apps/web/src/lib/recurrence.test.ts @@ -4,6 +4,9 @@ import { buildRrule, describeRecurrence, kindFromRrule, + matchPreset, + presetDescriptors, + presetToSpec, specFromRrule, type RecurrenceSpec, } from "./recurrence"; @@ -160,6 +163,91 @@ describe("specFromRrule / kindFromRrule", () => { }); }); +describe("preset shortcuts (Repeats picker)", () => { + // FIRST is 2026-05-13 = Wednesday (ISO weekday 3), day 13, May. + it("presetToSpec produces the canonical RecurrenceSpec for each shortcut", () => { + expect(presetToSpec("none", FIRST)).toMatchObject({ kind: "none" }); + expect(presetToSpec("daily", FIRST)).toMatchObject({ kind: "daily", interval: 1 }); + expect(presetToSpec("weekdays", FIRST)).toMatchObject({ + kind: "weekly", + weeklyDays: [1, 2, 3, 4, 5], + }); + expect(presetToSpec("weekends", FIRST)).toMatchObject({ + kind: "weekly", + weeklyDays: [6, 7], + }); + expect(presetToSpec("weekly_same", FIRST)).toMatchObject({ + kind: "weekly", + weeklyDays: [3], // Wed + }); + expect(presetToSpec("monthly_same", FIRST)).toMatchObject({ + kind: "monthly", + monthDay: 13, + }); + expect(presetToSpec("yearly_same", FIRST)).toMatchObject({ kind: "yearly" }); + }); + + it("matchPreset round-trips through presetToSpec for every preset", () => { + for (const id of ["none", "daily", "weekdays", "weekends", "weekly_same", "monthly_same", "yearly_same"] as const) { + const spec = presetToSpec(id, FIRST); + expect(matchPreset(spec, FIRST)).toBe(id); + } + }); + + it("matchPreset returns 'custom' for anything that doesn't fit a shortcut", () => { + // Interval > 1 doesn't match any preset. + expect( + matchPreset( + { kind: "daily", interval: 2, weeklyDays: [], end: { kind: "never" } }, + FIRST, + ), + ).toBe("custom"); + // Weekly Mon/Wed/Fri isn't a known shortcut. + expect( + matchPreset( + { kind: "weekly", interval: 1, weeklyDays: [1, 3, 5], end: { kind: "never" } }, + FIRST, + ), + ).toBe("custom"); + // End=after takes us out of the preset matrix even at interval=1. + expect( + matchPreset( + { kind: "daily", interval: 1, weeklyDays: [], end: { kind: "after", count: 5 } }, + FIRST, + ), + ).toBe("custom"); + // Monthly on a different day-of-month than the first fire. + expect( + matchPreset( + { kind: "monthly", interval: 1, weeklyDays: [], monthDay: 1, end: { kind: "never" } }, + FIRST, + ), + ).toBe("custom"); + }); + + it("presetDescriptors returns 8 entries with first-fire-aware labels", () => { + const items = presetDescriptors(FIRST); + expect(items.map((d) => d.id)).toEqual([ + "none", + "daily", + "weekdays", + "weekends", + "weekly_same", + "monthly_same", + "yearly_same", + "custom", + ]); + // Labels should be parameterised by firstFire. + expect(items.find((d) => d.id === "weekly_same")?.label).toBe("Every week on Wed"); + expect(items.find((d) => d.id === "monthly_same")?.label).toBe( + "Every month on day 13", + ); + expect(items.find((d) => d.id === "yearly_same")?.label).toBe( + "Every year on May 13", + ); + }); +}); + describe("describeRecurrence", () => { it("renders a one-off label", () => { expect(describeRecurrence(baseSpec({ kind: "none" }), FIRST)).toBe("One-off"); diff --git a/apps/web/src/lib/recurrence.ts b/apps/web/src/lib/recurrence.ts index fae5f1c..e64788d 100644 --- a/apps/web/src/lib/recurrence.ts +++ b/apps/web/src/lib/recurrence.ts @@ -194,3 +194,130 @@ export function specFromRrule(rrule: string | null | undefined): RecurrenceSpec export function kindFromRrule(rrule: string | null | undefined): RecurrenceKind { return specFromRrule(rrule).kind; } + +// --------------------------------------------------------------------------- +// Preset shortcuts for the Repeats picker +// --------------------------------------------------------------------------- +export type PresetId = + | "none" + | "daily" + | "weekdays" + | "weekends" + | "weekly_same" + | "monthly_same" + | "yearly_same" + | "custom"; + +export interface PresetDescriptor { + id: PresetId; + /** Short label shown in the radio list. */ + label: string; + /** Optional one-line hint shown beneath the label. */ + hint?: string; +} + +/** + * Build the canonical RecurrenceSpec for a preset given the first-fire + * DateTime (the spec depends on the chosen first fire — e.g. "every + * week on the same weekday" means whatever weekday firstFire lands on). + */ +export function presetToSpec(id: PresetId, firstFire: DateTime): RecurrenceSpec { + const base: RecurrenceSpec = { + kind: "none", + interval: 1, + weeklyDays: [], + end: { kind: "never" }, + }; + switch (id) { + case "none": + return base; + case "daily": + return { ...base, kind: "daily" }; + case "weekdays": + return { ...base, kind: "weekly", weeklyDays: [1, 2, 3, 4, 5] }; + case "weekends": + return { ...base, kind: "weekly", weeklyDays: [6, 7] }; + case "weekly_same": + return { ...base, kind: "weekly", weeklyDays: [firstFire.weekday] }; + case "monthly_same": + return { ...base, kind: "monthly", monthDay: firstFire.day }; + case "yearly_same": + return { ...base, kind: "yearly" }; + case "custom": + // Custom is only meaningful in the picker UI — when the user + // explicitly opts into it, callers should preserve whatever + // detailed spec the user already had. Return a sensible weekly + // default if the caller forgets to pass through. + return { ...base, kind: "weekly", weeklyDays: [firstFire.weekday] }; + } +} + +/** + * Reverse mapping: which preset (if any) does this spec match? + * + * Returns "custom" for anything that doesn't match a known shortcut — + * the picker uses that to flip into expanded-detail mode. + */ +export function matchPreset(spec: RecurrenceSpec, firstFire: DateTime): PresetId { + if (spec.kind === "none") return "none"; + + const sameInterval = spec.interval === 1; + const noEnd = spec.end.kind === "never"; + if (!sameInterval || !noEnd) return "custom"; + + const sortedWeeklyDays = (days: number[]) => days.slice().sort((a, b) => a - b).join(","); + + switch (spec.kind) { + case "daily": + return "daily"; + case "weekly": { + const days = spec.weeklyDays.length === 0 ? [firstFire.weekday] : spec.weeklyDays; + const key = sortedWeeklyDays(days); + if (key === "1,2,3,4,5") return "weekdays"; + if (key === "6,7") return "weekends"; + if (key === String(firstFire.weekday)) return "weekly_same"; + return "custom"; + } + case "monthly": + if ((spec.monthDay ?? firstFire.day) === firstFire.day) return "monthly_same"; + return "custom"; + case "yearly": + return "yearly_same"; + case "none": + return "none"; + } +} + +/** + * Render the (firstFire-aware) labels and hints for the radio list. + * The hint shows the concrete weekday/day-of-month/date the preset + * would imply given the user's chosen first fire. + */ +export function presetDescriptors(firstFire: DateTime): PresetDescriptor[] { + const dayShort = WEEKDAY_LABELS[firstFire.weekday - 1]?.short ?? ""; + return [ + { id: "none", label: "Don't repeat", hint: "Fires once and ends" }, + { id: "daily", label: "Every day" }, + { id: "weekdays", label: "Every weekday", hint: "Mon – Fri" }, + { id: "weekends", label: "Every weekend", hint: "Sat – Sun" }, + { + id: "weekly_same", + label: `Every week on ${dayShort}`, + hint: "Same weekday as the start date", + }, + { + id: "monthly_same", + label: `Every month on day ${firstFire.day}`, + hint: "Months without this day skip naturally (e.g. 31st)", + }, + { + id: "yearly_same", + label: `Every year on ${firstFire.toFormat("MMM d")}`, + }, + { + id: "custom", + label: "Custom…", + hint: "Set interval, days, and end conditions yourself", + }, + ]; +}