feat(recurrence): redesign Repeats picker as a preset radio list

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) <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-05-10 10:18:39 +08:00
parent 50df7fcb11
commit 991ff5fb22
5 changed files with 614 additions and 482 deletions

View File

@ -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<Exclude<RecurrenceKind, "none">, 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 (
<div className="space-y-2">
<Label className="flex items-center gap-1.5">
<RepeatIcon className="size-3.5" />
Repeats
</Label>
<div className="overflow-hidden rounded-xl border border-border bg-card">
<ul className="divide-y divide-border" role="radiogroup" aria-label="Repeat schedule">
{presetDescriptors(firstFire).map((p) => {
const selected = activePreset === p.id;
return (
<li key={p.id}>
<button
type="button"
role="radio"
aria-checked={selected}
onClick={() => pickPreset(p.id)}
className={cn(
"flex w-full items-center gap-3 px-4 py-3 text-left transition-colors",
selected
? "bg-primary/5 text-foreground"
: "hover:bg-muted text-foreground",
)}
>
{/* Radio dot */}
<span
aria-hidden
className={cn(
"flex size-4 shrink-0 items-center justify-center rounded-full border transition-colors",
selected
? "border-primary bg-primary text-primary-foreground"
: "border-input bg-background",
)}
>
{selected && <CheckIcon className="size-2.5" strokeWidth={3.5} />}
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium leading-snug">{p.label}</span>
{p.hint && (
<span className="block text-xs text-muted-foreground">{p.hint}</span>
)}
</span>
{p.id === "custom" && (
<span className="text-muted-foreground">
{customExpanded ? (
<ChevronUpIcon className="size-4" />
) : (
<ChevronDownIcon className="size-4" />
)}
</span>
)}
</button>
</li>
);
})}
</ul>
{customExpanded && (
<CustomPanel firstFire={firstFire} value={value} onChange={onChange} />
)}
</div>
</div>
);
}
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 (
<div className="space-y-4 border-t border-border bg-muted/20 p-4">
{/* Frequency */}
<div className="space-y-1.5">
<Label htmlFor="custom-freq" className="text-xs text-muted-foreground">
Frequency
</Label>
<select
id="custom-freq"
value={kind}
onChange={(e) => setKind(e.target.value as RecurrenceKind)}
className="h-8 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
<option value="yearly">Yearly</option>
</select>
</div>
{/* Interval */}
<div className="flex flex-wrap items-center gap-2">
<Label htmlFor="custom-interval" className="text-sm">
Every
</Label>
<Input
id="custom-interval"
type="number"
min={1}
max={999}
value={value.interval}
onChange={(e) => setInterval(Number(e.target.value))}
className="h-8 w-20"
/>
<span className="text-sm text-muted-foreground">
{FREQ_UNIT[kind]}
{value.interval === 1 ? "" : "s"}
</span>
</div>
{/* Weekly day picker */}
{kind === "weekly" && (
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">On these days</Label>
<div className="flex flex-wrap gap-1.5">
{WEEKDAY_LABELS.map(({ iso, short }) => {
const active = value.weeklyDays.includes(iso);
return (
<button
key={iso}
type="button"
onClick={() => toggleWeekday(iso)}
aria-pressed={active}
className={cn(
"inline-flex h-8 min-w-12 items-center justify-center rounded-lg border px-2.5 text-xs font-medium transition-colors",
active
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background hover:border-primary/50 hover:bg-primary/5 hover:text-primary",
)}
>
{short}
</button>
);
})}
</div>
</div>
)}
{/* Monthly day-of-month */}
{kind === "monthly" && (
<div className="space-y-1.5">
<Label htmlFor="custom-monthday" className="text-xs text-muted-foreground">
Day of the month
</Label>
<Input
id="custom-monthday"
type="number"
min={1}
max={31}
value={value.monthDay ?? ""}
onChange={(e) => {
const v = e.target.value;
if (v === "") setMonthDay("");
else setMonthDay(Number(v));
}}
placeholder={String(firstFire.day)}
className="h-8 w-24"
/>
<p className="text-xs text-muted-foreground">
Months without this day skip naturally (e.g. 31st).
</p>
</div>
)}
{/* End condition */}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">Ends</Label>
<div className="flex flex-wrap gap-1.5">
{(["never", "after", "on"] as const).map((v) => {
const active = value.end.kind === v;
const label = v === "never" ? "Never" : v === "after" ? "After…" : "On date…";
return (
<button
key={v}
type="button"
onClick={() => setEndKind(v)}
aria-pressed={active}
className={cn(
"inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium transition-colors",
active
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background hover:border-primary/50 hover:bg-primary/5 hover:text-primary",
)}
>
{label}
</button>
);
})}
</div>
{value.end.kind === "after" && (
<div className="flex items-center gap-2 pt-1">
<Input
type="number"
min={1}
max={9999}
value={value.end.count}
onChange={(e) => {
const n = Number(e.target.value);
onChange({
...value,
end: { kind: "after", count: Number.isFinite(n) && n >= 1 ? n : 1 },
});
}}
className="h-8 w-24"
/>
<span className="text-sm text-muted-foreground">
occurrence{value.end.count === 1 ? "" : "s"}
</span>
</div>
)}
{value.end.kind === "on" && (
<div className="pt-1">
<Input
type="date"
value={value.end.until}
onChange={(e) =>
onChange({ ...value, end: { kind: "on", until: e.target.value } })
}
className="h-8 w-44"
/>
</div>
)}
</div>
</div>
);
}

View File

@ -8,22 +8,18 @@ import {
CalendarIcon, CalendarIcon,
ClockIcon, ClockIcon,
Loader2Icon, Loader2Icon,
RepeatIcon,
SaveIcon, SaveIcon,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { splitDateTime, validateScheduledAt } from "@/lib/date-picker"; import { splitDateTime, validateScheduledAt } from "@/lib/date-picker";
import { import {
WEEKDAY_LABELS,
buildRrule, buildRrule,
describeRecurrence, describeRecurrence,
type EndKind,
type RecurrenceKind,
type RecurrenceSpec, type RecurrenceSpec,
} from "@/lib/recurrence"; } from "@/lib/recurrence";
import { RecurrencePicker } from "@/components/recurrence-picker";
import { updateReminderAction } from "@/actions/reminders"; import { updateReminderAction } from "@/actions/reminders";
interface EditWhenFormProps { interface EditWhenFormProps {
@ -38,20 +34,6 @@ interface EditWhenFormProps {
timezone: string; 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<Exclude<RecurrenceKind, "none">, string> = {
daily: "day",
weekly: "week",
monthly: "month",
yearly: "year",
};
export function EditWhenForm({ export function EditWhenForm({
reminderId, reminderId,
accountId, accountId,
@ -68,45 +50,17 @@ export function EditWhenForm({
const [date, setDate] = useState(initial.date); const [date, setDate] = useState(initial.date);
const [time, setTime] = useState(initial.time); const [time, setTime] = useState(initial.time);
const [kind, setKind] = useState<RecurrenceKind>(initialSpec.kind); const [spec, setSpec] = useState<RecurrenceSpec>(initialSpec);
const [interval, setIntervalValue] = useState<number>(initialSpec.interval);
const [weeklyDays, setWeeklyDays] = useState<number[]>(initialSpec.weeklyDays);
const [monthDay, setMonthDay] = useState<number | "">(initialSpec.monthDay ?? "");
const [endKind, setEndKind] = useState<EndKind>(initialSpec.end.kind);
const [endCount, setEndCount] = useState<number>(
initialSpec.end.kind === "after" ? initialSpec.end.count : 10,
);
const [endUntil, setEndUntil] = useState<string>(
initialSpec.end.kind === "on" ? initialSpec.end.until : "",
);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
function toggleWeekday(iso: number) { const previewDt = (() => {
setWeeklyDays((prev) => if (date && time) {
prev.includes(iso) ? prev.filter((d) => d !== iso) : [...prev, iso].sort((a, b) => a - b), const d = DateTime.fromFormat(`${date} ${time}`, "yyyy-MM-dd HH:mm", { zone: timezone });
); if (d.isValid) return d;
}
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 };
} }
return { return DateTime.fromISO(initialIso, { zone: timezone });
kind, })();
interval: Math.max(1, Math.floor(interval || 1)),
weeklyDays,
monthDay: kind === "monthly" ? safeMonthDay : undefined,
end,
};
}
async function handleSave() { async function handleSave() {
const v = validateScheduledAt(date, time, timezone, Date.now()); const v = validateScheduledAt(date, time, timezone, Date.now());
@ -119,12 +73,11 @@ export function EditWhenForm({
setError(map[v.reason]); setError(map[v.reason]);
return; return;
} }
if (endKind === "on" && !endUntil) { if (spec.end.kind === "on" && !spec.end.until) {
setError("Pick the end date for this recurrence."); setError("Pick the end date for this recurrence.");
return; return;
} }
const dt = v.dt; const dt = v.dt;
const spec = buildSpec(dt);
const rrule = buildRrule(spec, dt); const rrule = buildRrule(spec, dt);
setSubmitting(true); setSubmitting(true);
@ -154,14 +107,7 @@ export function EditWhenForm({
} }
} }
const previewDt = const previewSentence = describeRecurrence(spec, 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;
return ( return (
<div className="space-y-5"> <div className="space-y-5">
@ -169,7 +115,7 @@ export function EditWhenForm({
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="when-date" className="flex items-center gap-1.5"> <Label htmlFor="when-date" className="flex items-center gap-1.5">
<CalendarIcon className="size-3.5" /> <CalendarIcon className="size-3.5" />
{kind === "none" ? "Date" : "Starts on"} {spec.kind === "none" ? "Date" : "Starts on"}
</Label> </Label>
<Input <Input
id="when-date" id="when-date"
@ -200,170 +146,7 @@ export function EditWhenForm({
</div> </div>
</div> </div>
<div className="space-y-2"> <RecurrencePicker firstFire={previewDt} value={spec} onChange={setSpec} />
<Label className="flex items-center gap-1.5">
<RepeatIcon className="size-3.5" />
Repeats
</Label>
<div className="flex flex-wrap gap-1.5">
{KINDS.map(({ value, label }) => {
const active = kind === value;
return (
<button
key={value}
type="button"
onClick={() => setKind(value)}
aria-pressed={active}
className={cn(
"inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium transition-colors",
active
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-muted/50 hover:border-primary/50 hover:bg-primary/5 hover:text-primary",
)}
>
{label}
</button>
);
})}
</div>
</div>
{kind !== "none" && (
<div className="space-y-4 rounded-xl border border-border bg-muted/30 p-4">
<div className="flex flex-wrap items-center gap-2">
<Label htmlFor="when-interval" className="text-sm">
Every
</Label>
<Input
id="when-interval"
type="number"
min={1}
max={999}
value={interval}
onChange={(e) => {
const n = Number(e.target.value);
setIntervalValue(Number.isFinite(n) && n >= 1 ? n : 1);
setError(null);
}}
className="h-8 w-20"
/>
<span className="text-sm text-muted-foreground">
{FREQ_UNIT[kind]}
{interval === 1 ? "" : "s"}
</span>
</div>
{kind === "weekly" && (
<div className="space-y-2">
<Label className="text-sm">On these days</Label>
<div className="flex flex-wrap gap-1.5">
{WEEKDAY_LABELS.map(({ iso, short }) => {
const active = weeklyDays.includes(iso);
return (
<button
key={iso}
type="button"
onClick={() => toggleWeekday(iso)}
aria-pressed={active}
className={cn(
"inline-flex h-8 min-w-12 items-center justify-center rounded-lg border px-2.5 text-xs font-medium transition-colors",
active
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background hover:border-primary/50 hover:bg-primary/5 hover:text-primary",
)}
>
{short}
</button>
);
})}
</div>
</div>
)}
{kind === "monthly" && (
<div className="space-y-1.5">
<Label htmlFor="when-monthday" className="text-sm">
Day of the month
</Label>
<Input
id="when-monthday"
type="number"
min={1}
max={31}
value={monthDay}
onChange={(e) => {
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"
/>
</div>
)}
<div className="space-y-2">
<Label className="text-sm">Ends</Label>
<div className="flex flex-wrap gap-1.5">
{(["never", "after", "on"] as const).map((v) => {
const active = endKind === v;
const label = v === "never" ? "Never" : v === "after" ? "After…" : "On date…";
return (
<button
key={v}
type="button"
onClick={() => setEndKind(v)}
aria-pressed={active}
className={cn(
"inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium transition-colors",
active
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background hover:border-primary/50 hover:bg-primary/5 hover:text-primary",
)}
>
{label}
</button>
);
})}
</div>
{endKind === "after" && (
<div className="flex items-center gap-2 pt-1">
<Input
type="number"
min={1}
max={9999}
value={endCount}
onChange={(e) => {
const n = Number(e.target.value);
setEndCount(Number.isFinite(n) && n >= 1 ? n : 1);
setError(null);
}}
className="h-8 w-24"
/>
<span className="text-sm text-muted-foreground">
occurrence{endCount === 1 ? "" : "s"}
</span>
</div>
)}
{endKind === "on" && (
<div className="pt-1">
<Input
type="date"
value={endUntil}
onChange={(e) => {
setEndUntil(e.target.value);
setError(null);
}}
className="h-8 w-44"
/>
</div>
)}
</div>
</div>
)}
{previewSentence && ( {previewSentence && (
<p className="rounded-lg bg-primary/5 px-3 py-2 text-xs text-primary/80"> <p className="rounded-lg bg-primary/5 px-3 py-2 text-xs text-primary/80">

View File

@ -3,20 +3,18 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { DateTime } from "luxon"; 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 { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { import {
WEEKDAY_LABELS,
buildRrule, buildRrule,
describeRecurrence, describeRecurrence,
type RecurrenceKind, DEFAULT_RECURRENCE,
type RecurrenceSpec, type RecurrenceSpec,
type EndKind,
} from "@/lib/recurrence"; } from "@/lib/recurrence";
import { splitDateTime, validateScheduledAt } from "@/lib/date-picker"; import { splitDateTime, validateScheduledAt } from "@/lib/date-picker";
import { RecurrencePicker } from "@/components/recurrence-picker";
interface PassThroughParams { interface PassThroughParams {
text?: string; text?: string;
@ -34,21 +32,6 @@ interface WhenFormClientProps {
passThroughParams: PassThroughParams; 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<Exclude<RecurrenceKind, "none">, string> = {
daily: "day",
weekly: "week",
monthly: "month",
yearly: "year",
};
export function WhenFormClient({ export function WhenFormClient({
accountId, accountId,
groupIds, groupIds,
@ -62,75 +45,46 @@ export function WhenFormClient({
const [date, setDate] = useState(initial.date); const [date, setDate] = useState(initial.date);
const [time, setTime] = useState(initial.time); const [time, setTime] = useState(initial.time);
const [kind, setKind] = useState<RecurrenceKind>(initialSpec?.kind ?? "none"); const [spec, setSpec] = useState<RecurrenceSpec>(initialSpec ?? DEFAULT_RECURRENCE);
const [interval, setIntervalValue] = useState<number>(initialSpec?.interval ?? 1);
const [weeklyDays, setWeeklyDays] = useState<number[]>(initialSpec?.weeklyDays ?? []);
const [monthDay, setMonthDay] = useState<number | "">(
initialSpec?.monthDay ?? "",
);
const [endKind, setEndKind] = useState<EndKind>(initialSpec?.end.kind ?? "never");
const [endCount, setEndCount] = useState<number>(
initialSpec?.end.kind === "after" ? initialSpec.end.count : 10,
);
const [endUntil, setEndUntil] = useState<string>(
initialSpec?.end.kind === "on" ? initialSpec.end.until : "",
);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
function toggleWeekday(iso: number) { // The first-fire DateTime drives preset labels in the picker. Fall
setWeeklyDays((prev) => // back to the default ISO if the inputs aren't a valid pair yet.
prev.includes(iso) ? prev.filter((d) => d !== iso) : [...prev, iso].sort((a, b) => a - b), const previewDt = (() => {
); if (date && time) {
} const d = DateTime.fromFormat(`${date} ${time}`, "yyyy-MM-dd HH:mm", { zone: timezone });
if (d.isValid) return d;
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 };
} }
return { return DateTime.fromISO(initialDefaultIso, { zone: timezone });
kind, })();
interval: Math.max(1, Math.floor(interval || 1)),
weeklyDays,
monthDay: kind === "monthly" ? safeMonthDay : undefined,
end,
};
}
function handleContinue() { function handleContinue() {
const v = validateScheduledAt(date, time, timezone, Date.now()); const v = validateScheduledAt(date, time, timezone, Date.now());
if (!v.ok) { if (!v.ok) {
const map: Record<typeof v.reason, string> = { const map = {
missing: "Pick both a date and a time.", missing: "Pick both a date and a time.",
invalid: "Invalid date or time.", invalid: "Invalid date or time.",
past: "The first occurrence is in the past. Pick a future date and time.", past: "The first occurrence is in the past. Pick a future date and time.",
}; } as const;
setError(map[v.reason]); setError(map[v.reason]);
return; return;
} }
const dt = v.dt; const dt = v.dt;
if (endKind === "on" && !endUntil) { if (spec.end.kind === "on" && !spec.end.until) {
setError("Pick the end date for this recurrence."); setError("Pick the end date for this recurrence.");
return; return;
} }
if (endKind === "on" && endUntil) { if (spec.end.kind === "on" && spec.end.until) {
const until = DateTime.fromISO(endUntil, { zone: timezone }); const until = DateTime.fromISO(spec.end.until, { zone: timezone });
if (until.isValid && until.toMillis() <= dt.toMillis()) { if (until.isValid && until.toMillis() <= dt.toMillis()) {
setError("The end date must be after the first fire."); setError("The end date must be after the first fire.");
return; 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."); setError("Number of occurrences must be at least 1.");
return; return;
} }
const spec = buildSpec(dt);
const rrule = buildRrule(spec, dt); const rrule = buildRrule(spec, dt);
const scheduledAt = dt.toISO()!; const scheduledAt = dt.toISO()!;
const sp = new URLSearchParams({ const sp = new URLSearchParams({
@ -148,15 +102,7 @@ export function WhenFormClient({
router.push(`/reminders/new?${sp.toString()}` as any); router.push(`/reminders/new?${sp.toString()}` as any);
} }
// Live preview text — uses the parsed first-fire if valid, else the date input alone. const previewSentence = describeRecurrence(spec, previewDt);
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;
return ( return (
<div className="space-y-5"> <div className="space-y-5">
@ -165,7 +111,7 @@ export function WhenFormClient({
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="scheduled-date" className="flex items-center gap-1.5"> <Label htmlFor="scheduled-date" className="flex items-center gap-1.5">
<CalendarIcon className="size-3.5" /> <CalendarIcon className="size-3.5" />
{kind === "none" ? "Date" : "Starts on"} {spec.kind === "none" ? "Date" : "Starts on"}
</Label> </Label>
<Input <Input
id="scheduled-date" id="scheduled-date"
@ -196,186 +142,8 @@ export function WhenFormClient({
</div> </div>
</div> </div>
{/* Frequency */} <RecurrencePicker firstFire={previewDt} value={spec} onChange={setSpec} />
<div className="space-y-2">
<Label className="flex items-center gap-1.5">
<RepeatIcon className="size-3.5" />
Repeats
</Label>
<div className="flex flex-wrap gap-1.5">
{KINDS.map(({ value, label }) => {
const active = kind === value;
return (
<button
key={value}
type="button"
onClick={() => setKind(value)}
aria-pressed={active}
className={cn(
"inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium transition-colors",
active
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-muted/50 hover:border-primary/50 hover:bg-primary/5 hover:text-primary",
)}
>
{label}
</button>
);
})}
</div>
</div>
{/* Recurrence detail — interval, weekdays, monthday, end */}
{kind !== "none" && (
<div className="space-y-4 rounded-xl border border-border bg-muted/30 p-4">
{/* Interval */}
<div className="flex flex-wrap items-center gap-2">
<Label htmlFor="recur-interval" className="text-sm">
Every
</Label>
<Input
id="recur-interval"
type="number"
min={1}
max={999}
value={interval}
onChange={(e) => {
const n = Number(e.target.value);
setIntervalValue(Number.isFinite(n) && n >= 1 ? n : 1);
setError(null);
}}
className="h-8 w-20"
/>
<span className="text-sm text-muted-foreground">
{FREQ_UNIT[kind]}
{interval === 1 ? "" : "s"}
</span>
</div>
{/* Weekly days */}
{kind === "weekly" && (
<div className="space-y-2">
<Label className="text-sm">On these days</Label>
<div className="flex flex-wrap gap-1.5">
{WEEKDAY_LABELS.map(({ iso, short }) => {
const active = weeklyDays.includes(iso);
return (
<button
key={iso}
type="button"
onClick={() => toggleWeekday(iso)}
aria-pressed={active}
className={cn(
"inline-flex h-8 min-w-12 items-center justify-center rounded-lg border px-2.5 text-xs font-medium transition-colors",
active
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background hover:border-primary/50 hover:bg-primary/5 hover:text-primary",
)}
>
{short}
</button>
);
})}
</div>
<p className="text-xs text-muted-foreground">
Leave empty to use the start date's weekday only.
</p>
</div>
)}
{/* Monthly day-of-month */}
{kind === "monthly" && (
<div className="space-y-1.5">
<Label htmlFor="recur-monthday" className="text-sm">
Day of the month
</Label>
<Input
id="recur-monthday"
type="number"
min={1}
max={31}
value={monthDay}
onChange={(e) => {
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"
/>
<p className="text-xs text-muted-foreground">
Months without this day skip naturally (e.g. 31st).
</p>
</div>
)}
{/* End condition */}
<div className="space-y-2">
<Label className="text-sm">Ends</Label>
<div className="flex flex-wrap gap-1.5">
{(["never", "after", "on"] as const).map((v) => {
const active = endKind === v;
const label = v === "never" ? "Never" : v === "after" ? "After…" : "On date…";
return (
<button
key={v}
type="button"
onClick={() => setEndKind(v)}
aria-pressed={active}
className={cn(
"inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium transition-colors",
active
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background hover:border-primary/50 hover:bg-primary/5 hover:text-primary",
)}
>
{label}
</button>
);
})}
</div>
{endKind === "after" && (
<div className="flex items-center gap-2 pt-1">
<Input
type="number"
min={1}
max={9999}
value={endCount}
onChange={(e) => {
const n = Number(e.target.value);
setEndCount(Number.isFinite(n) && n >= 1 ? n : 1);
setError(null);
}}
className="h-8 w-24"
/>
<span className="text-sm text-muted-foreground">
occurrence{endCount === 1 ? "" : "s"}
</span>
</div>
)}
{endKind === "on" && (
<div className="pt-1">
<Input
type="date"
value={endUntil}
onChange={(e) => {
setEndUntil(e.target.value);
setError(null);
}}
className="h-8 w-44"
/>
</div>
)}
</div>
</div>
)}
{/* Live preview */}
{previewSentence && ( {previewSentence && (
<p className="rounded-lg bg-primary/5 px-3 py-2 text-xs text-primary/80"> <p className="rounded-lg bg-primary/5 px-3 py-2 text-xs text-primary/80">
{previewSentence} {previewSentence}

View File

@ -4,6 +4,9 @@ import {
buildRrule, buildRrule,
describeRecurrence, describeRecurrence,
kindFromRrule, kindFromRrule,
matchPreset,
presetDescriptors,
presetToSpec,
specFromRrule, specFromRrule,
type RecurrenceSpec, type RecurrenceSpec,
} from "./recurrence"; } 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", () => { describe("describeRecurrence", () => {
it("renders a one-off label", () => { it("renders a one-off label", () => {
expect(describeRecurrence(baseSpec({ kind: "none" }), FIRST)).toBe("One-off"); expect(describeRecurrence(baseSpec({ kind: "none" }), FIRST)).toBe("One-off");

View File

@ -194,3 +194,130 @@ export function specFromRrule(rrule: string | null | undefined): RecurrenceSpec
export function kindFromRrule(rrule: string | null | undefined): RecurrenceKind { export function kindFromRrule(rrule: string | null | undefined): RecurrenceKind {
return specFromRrule(rrule).kind; 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",
},
];
}