feat(recurrence): per-rule fire time on every tab; drop redundant labels

Each schedule row in the recurrence picker now owns its own HH:MM
fire time. Previously every row inherited the time from the date+time
inputs above, which meant you couldn't say "every Monday at 09:00 AND
every Friday at 17:00" — both rules shared whatever the form-level
time was.

The Daily / Weekly / Monthly / Yearly tabs each render a small
"Fires at" time field. The picker still seeds new rules with the
form's date+time so the most common case (one rule, time matches the
first fire) doesn't need extra clicks.

Round-trip: the cron line `35 17 * * 1` now restores hour=17, minute=35
on a weekly draft. The parser pulls MM HH off the front of every
expression and feeds the rest of the pattern matchers as before.

Also clean up two pieces of duplicated/obsolete UI feedback in both
the wizard When step and the edit When form:

  - Removed the `<p>` showing "Cron: 0 9 * * *" — the per-row
    description ("Every day at 09:00") already says it, in human form.
  - Removed the standalone "Times are in Asia/Kuala_Lumpur" footer.
    The timezone is shown elsewhere (header) and the cron output is
    always evaluated in the configured zone — telling the user "times
    are in <tz>" inline in the picker is noise.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-05-10 11:18:23 +08:00
parent 797917a4ba
commit 08435988c2
3 changed files with 143 additions and 108 deletions

View File

@ -15,7 +15,12 @@ import {
} from "@/lib/recurrence"; } from "@/lib/recurrence";
interface RecurrencePickerProps { interface RecurrencePickerProps {
/** First fire — drives the HH:MM in every row's cron output. */ /**
* First fire only used to seed defaults for newly-added rules
* (initial weekday, monthday, time). Each rule then carries its
* own hour/minute, so changing the date+time inputs above no
* longer reshapes existing rules.
*/
firstFire: DateTime; firstFire: DateTime;
value: RecurrenceSpec; value: RecurrenceSpec;
onChange: (next: RecurrenceSpec) => void; onChange: (next: RecurrenceSpec) => void;
@ -34,6 +39,10 @@ interface Draft {
weekdays: number[]; weekdays: number[];
monthDay: number; monthDay: number;
month: number; month: number;
/** Hour-of-day (0-23) for this rule's fire time. */
hour: number;
/** Minute-of-hour (0-59). */
minute: number;
} }
const MAX_RULES = 8; const MAX_RULES = 8;
@ -45,9 +54,24 @@ function defaultDraft(firstFire: DateTime): Draft {
weekdays: [isoWeekdayToCron(firstFire.weekday)], weekdays: [isoWeekdayToCron(firstFire.weekday)],
monthDay: firstFire.day, monthDay: firstFire.day,
month: firstFire.month, month: firstFire.month,
hour: firstFire.hour,
minute: firstFire.minute,
}; };
} }
function pad2(n: number): string {
return n.toString().padStart(2, "0");
}
function parseHHMM(s: string): { hour: number; minute: number } | null {
const m = s.match(/^(\d{1,2}):(\d{2})$/);
if (!m) return null;
const h = Number(m[1]);
const min = Number(m[2]);
if (h < 0 || h > 23 || min < 0 || min > 59) return null;
return { hour: h, minute: min };
}
function clamp(n: number, lo: number, hi: number): number { function clamp(n: number, lo: number, hi: number): number {
if (!Number.isFinite(n)) return lo; if (!Number.isFinite(n)) return lo;
return Math.min(Math.max(Math.floor(n), lo), hi); return Math.min(Math.max(Math.floor(n), lo), hi);
@ -58,9 +82,9 @@ const MONTH_NAMES = [
"July", "August", "September", "October", "November", "December", "July", "August", "September", "October", "November", "December",
]; ];
function draftToCron(d: Draft, firstFire: DateTime): string | null { function draftToCron(d: Draft): string | null {
const m = firstFire.minute; const m = clamp(d.minute, 0, 59);
const h = firstFire.hour; const h = clamp(d.hour, 0, 23);
switch (d.type) { switch (d.type) {
case "daily": case "daily":
return d.dailyMode === "weekdays" ? `${m} ${h} * * 1-5` : `${m} ${h} * * *`; return d.dailyMode === "weekdays" ? `${m} ${h} * * 1-5` : `${m} ${h} * * *`;
@ -74,8 +98,8 @@ function draftToCron(d: Draft, firstFire: DateTime): string | null {
} }
} }
function describeDraft(d: Draft, firstFire: DateTime): string { function describeDraft(d: Draft): string {
const t = firstFire.toFormat("HH:mm"); const t = `${pad2(clamp(d.hour, 0, 23))}:${pad2(clamp(d.minute, 0, 59))}`;
switch (d.type) { switch (d.type) {
case "daily": case "daily":
return d.dailyMode === "weekdays" return d.dailyMode === "weekdays"
@ -105,14 +129,21 @@ function describeDraft(d: Draft, firstFire: DateTime): string {
* daily-every-day if the expression doesn't match a known shape. */ * daily-every-day if the expression doesn't match a known shape. */
function draftFromCronExpr(expr: string, firstFire: DateTime): Draft { function draftFromCronExpr(expr: string, firstFire: DateTime): Draft {
const base = defaultDraft(firstFire); const base = defaultDraft(firstFire);
// Pull MM HH off the front so each rule restores its own time.
const head = expr.match(/^(\d+)\s+(\d+)\s+(.*)$/);
if (!head) return base;
const minute = clamp(Number(head[1]), 0, 59);
const hour = clamp(Number(head[2]), 0, 23);
const rest = head[3]!.trim();
let m: RegExpMatchArray | null; let m: RegExpMatchArray | null;
if (expr.match(/^\d+ \d+ \* \* 1-5$/)) { if (rest === "* * 1-5") {
return { ...base, type: "daily", dailyMode: "weekdays" }; return { ...base, type: "daily", dailyMode: "weekdays", hour, minute };
} }
if (expr.match(/^\d+ \d+ \* \* \*$/)) { if (rest === "* * *") {
return { ...base, type: "daily", dailyMode: "every_day" }; return { ...base, type: "daily", dailyMode: "every_day", hour, minute };
} }
if ((m = expr.match(/^\d+ \d+ \* \* ([0-9,\-]+)$/))) { if ((m = rest.match(/^\* \* ([0-9,\-]+)$/))) {
const days = m[1]! const days = m[1]!
.split(",") .split(",")
.flatMap((p) => { .flatMap((p) => {
@ -125,15 +156,22 @@ function draftFromCronExpr(expr: string, firstFire: DateTime): Draft {
return [Number(p)]; return [Number(p)];
}) })
.filter((n) => n >= 0 && n <= 6); .filter((n) => n >= 0 && n <= 6);
return { ...base, type: "weekly", weekdays: days }; return { ...base, type: "weekly", weekdays: days, hour, minute };
} }
if ((m = expr.match(/^\d+ \d+ (\d+) \* \*$/))) { if ((m = rest.match(/^(\d+) \* \*$/))) {
return { ...base, type: "monthly", monthDay: Number(m[1]) }; return { ...base, type: "monthly", monthDay: Number(m[1]), hour, minute };
} }
if ((m = expr.match(/^\d+ \d+ (\d+) (\d+) \*$/))) { if ((m = rest.match(/^(\d+) (\d+) \*$/))) {
return { ...base, type: "yearly", monthDay: Number(m[1]), month: Number(m[2]) }; return {
...base,
type: "yearly",
monthDay: Number(m[1]),
month: Number(m[2]),
hour,
minute,
};
} }
return base; return { ...base, hour, minute };
} }
/** Parse a (possibly multi-line) cron rule into an array of drafts. */ /** Parse a (possibly multi-line) cron rule into an array of drafts. */
@ -147,10 +185,10 @@ function draftsFromRule(rule: string | null | undefined, firstFire: DateTime): D
/** Compile an array of drafts to a single multi-line CRON: rule, or null /** Compile an array of drafts to a single multi-line CRON: rule, or null
* if there are no rules (= one-off). */ * if there are no rules (= one-off). */
function draftsToRule(drafts: Draft[], firstFire: DateTime): string | null { function draftsToRule(drafts: Draft[]): string | null {
if (drafts.length === 0) return null; if (drafts.length === 0) return null;
const exprs = drafts const exprs = drafts
.map((d) => draftToCron(d, firstFire)) .map((d) => draftToCron(d))
.filter((s): s is string => Boolean(s)); .filter((s): s is string => Boolean(s));
if (exprs.length === 0) return null; if (exprs.length === 0) return null;
return exprs.join("\n"); return exprs.join("\n");
@ -190,11 +228,11 @@ export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePicke
draftsFromRule(value.kind === "cron" ? value.cron ?? null : null, firstFire), draftsFromRule(value.kind === "cron" ? value.cron ?? null : null, firstFire),
); );
// Compile drafts back to the parent value whenever they change. Also // Compile drafts back to the parent value whenever they change. Each
// re-runs when first-fire moves (changing the time picker reshapes // rule carries its own hour/minute now, so the date+time inputs above
// every row's cron output). // no longer drive cron output — they only set the very first fire.
useEffect(() => { useEffect(() => {
const rule = draftsToRule(drafts, firstFire); const rule = draftsToRule(drafts);
if (!rule) { if (!rule) {
if (value.kind !== "none") { if (value.kind !== "none") {
onChange({ kind: "none", interval: 1, weeklyDays: [], end: { kind: "never" } }); onChange({ kind: "none", interval: 1, weeklyDays: [], end: { kind: "never" } });
@ -211,7 +249,7 @@ export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePicke
}); });
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [drafts, firstFire.minute, firstFire.hour, firstFire.day, firstFire.month, firstFire.weekday]); }, [drafts]);
function updateDraft(idx: number, patch: Partial<Draft>) { function updateDraft(idx: number, patch: Partial<Draft>) {
setDrafts((prev) => prev.map((d, i) => (i === idx ? { ...d, ...patch } : d))); setDrafts((prev) => prev.map((d, i) => (i === idx ? { ...d, ...patch } : d)));
@ -263,12 +301,11 @@ export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePicke
<RuleEditor <RuleEditor
draft={draft} draft={draft}
firstFire={firstFire}
onChange={(patch) => updateDraft(idx, patch)} onChange={(patch) => updateDraft(idx, patch)}
/> />
<p className="rounded-lg bg-primary/5 px-2.5 py-1.5 text-xs text-primary/80"> <p className="rounded-lg bg-primary/5 px-2.5 py-1.5 text-xs text-primary/80">
{describeDraft(draft, firstFire)} {describeDraft(draft)}
</p> </p>
</div> </div>
))} ))}
@ -303,11 +340,10 @@ export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePicke
interface RuleEditorProps { interface RuleEditorProps {
draft: Draft; draft: Draft;
firstFire: DateTime;
onChange: (patch: Partial<Draft>) => void; onChange: (patch: Partial<Draft>) => void;
} }
function RuleEditor({ draft, firstFire, onChange }: RuleEditorProps) { function RuleEditor({ draft, onChange }: RuleEditorProps) {
return ( return (
<Tabs <Tabs
value={draft.type} value={draft.type}
@ -320,7 +356,7 @@ function RuleEditor({ draft, firstFire, onChange }: RuleEditorProps) {
<TabsTrigger value="yearly">Yearly</TabsTrigger> <TabsTrigger value="yearly">Yearly</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="daily" className="space-y-2 pt-3"> <TabsContent value="daily" className="space-y-3 pt-3">
<RadioRow <RadioRow
name={`daily-${draft.type}`} name={`daily-${draft.type}`}
checked={draft.dailyMode === "every_day"} checked={draft.dailyMode === "every_day"}
@ -333,58 +369,65 @@ function RuleEditor({ draft, firstFire, onChange }: RuleEditorProps) {
onChange={() => onChange({ dailyMode: "weekdays" })} onChange={() => onChange({ dailyMode: "weekdays" })}
label="Every weekday (Mon Fri)" label="Every weekday (Mon Fri)"
/> />
<TimeField draft={draft} onChange={onChange} />
</TabsContent> </TabsContent>
<TabsContent value="weekly" className="space-y-2 pt-3"> <TabsContent value="weekly" className="space-y-3 pt-3">
<Label className="text-sm">On these days</Label> <div className="space-y-2">
<div className="flex flex-wrap gap-1.5"> <Label className="text-sm">On these days</Label>
{WEEKDAY_LABELS.map(({ iso, short }) => { <div className="flex flex-wrap gap-1.5">
const cronDow = isoWeekdayToCron(iso); {WEEKDAY_LABELS.map(({ iso, short }) => {
const active = draft.weekdays.includes(cronDow); const cronDow = isoWeekdayToCron(iso);
return ( const active = draft.weekdays.includes(cronDow);
<button return (
key={iso} <button
type="button" key={iso}
onClick={() => type="button"
onChange({ onClick={() =>
weekdays: active onChange({
? draft.weekdays.filter((d) => d !== cronDow) weekdays: active
: [...draft.weekdays, cronDow].sort((a, b) => a - b), ? draft.weekdays.filter((d) => d !== cronDow)
}) : [...draft.weekdays, cronDow].sort((a, b) => a - b),
} })
aria-pressed={active} }
className={cn( aria-pressed={active}
"inline-flex h-8 min-w-12 items-center justify-center rounded-lg border px-2.5 text-xs font-medium transition-colors", className={cn(
active "inline-flex h-8 min-w-12 items-center justify-center rounded-lg border px-2.5 text-xs font-medium transition-colors",
? "border-primary bg-primary text-primary-foreground" active
: "border-border bg-background hover:border-primary/50 hover:bg-primary/5 hover:text-primary", ? "border-primary bg-primary text-primary-foreground"
)} : "border-border bg-background hover:border-primary/50 hover:bg-primary/5 hover:text-primary",
> )}
{short} >
</button> {short}
); </button>
})} );
})}
</div>
</div> </div>
<TimeField draft={draft} onChange={onChange} />
</TabsContent> </TabsContent>
<TabsContent value="monthly" className="space-y-2 pt-3"> <TabsContent value="monthly" className="space-y-3 pt-3">
<Label className="text-sm">Day of the month</Label> <div className="space-y-2">
<div className="flex items-center gap-2"> <Label className="text-sm">Day of the month</Label>
<Input <div className="flex items-center gap-2">
type="number" <Input
min={1} type="number"
max={31} min={1}
value={draft.monthDay} max={31}
onChange={(e) => onChange({ monthDay: Number(e.target.value) || 1 })} value={draft.monthDay}
className="h-8 w-24" onChange={(e) => onChange({ monthDay: Number(e.target.value) || 1 })}
/> className="h-8 w-24"
<span className="text-xs text-muted-foreground"> />
Months without this day skip naturally (e.g. 31st) <span className="text-xs text-muted-foreground">
</span> Months without this day skip naturally (e.g. 31st)
</span>
</div>
</div> </div>
<TimeField draft={draft} onChange={onChange} />
</TabsContent> </TabsContent>
<TabsContent value="yearly" className="pt-3"> <TabsContent value="yearly" className="space-y-3 pt-3">
<div className="flex flex-wrap items-end gap-3"> <div className="flex flex-wrap items-end gap-3">
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs text-muted-foreground">Month</Label> <Label className="text-xs text-muted-foreground">Month</Label>
@ -412,11 +455,36 @@ function RuleEditor({ draft, firstFire, onChange }: RuleEditorProps) {
/> />
</div> </div>
</div> </div>
<TimeField draft={draft} onChange={onChange} />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
); );
} }
interface TimeFieldProps {
draft: Draft;
onChange: (patch: Partial<Draft>) => void;
}
/** Per-rule time picker (HH:MM). Drives the cron expression's MM HH columns. */
function TimeField({ draft, onChange }: TimeFieldProps) {
const value = `${pad2(clamp(draft.hour, 0, 23))}:${pad2(clamp(draft.minute, 0, 59))}`;
return (
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Fires at</Label>
<Input
type="time"
value={value}
onChange={(e) => {
const parsed = parseHHMM(e.target.value);
if (parsed) onChange(parsed);
}}
className="h-8 w-32"
/>
</div>
);
}
interface RadioRowProps { interface RadioRowProps {
name: string; name: string;
checked: boolean; checked: boolean;

View File

@ -14,11 +14,7 @@ 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 { splitDateTime, validateScheduledAt } from "@/lib/date-picker"; import { splitDateTime, validateScheduledAt } from "@/lib/date-picker";
import { import { buildRrule, type RecurrenceSpec } from "@/lib/recurrence";
buildRrule,
describeRecurrence,
type RecurrenceSpec,
} from "@/lib/recurrence";
import { RecurrencePicker } from "@/components/recurrence-picker"; import { RecurrencePicker } from "@/components/recurrence-picker";
import { updateReminderAction } from "@/actions/reminders"; import { updateReminderAction } from "@/actions/reminders";
@ -121,8 +117,6 @@ export function EditWhenForm({
} }
} }
const previewSentence = describeRecurrence(spec, previewDt);
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
@ -162,12 +156,6 @@ export function EditWhenForm({
<RecurrencePicker firstFire={previewDt} value={spec} onChange={setSpec} /> <RecurrencePicker firstFire={previewDt} value={spec} onChange={setSpec} />
{previewSentence && (
<p className="rounded-lg bg-primary/5 px-3 py-2 text-xs text-primary/80">
{previewSentence}
</p>
)}
{error && ( {error && (
<div className="flex items-center gap-1.5 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive"> <div className="flex items-center gap-1.5 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertCircleIcon className="size-3.5 shrink-0" /> <AlertCircleIcon className="size-3.5 shrink-0" />
@ -175,10 +163,6 @@ export function EditWhenForm({
</div> </div>
)} )}
<p className="text-xs text-muted-foreground">
Times are in <span className="font-medium">{timezone}</span>.
</p>
<div className="flex justify-end"> <div className="flex justify-end">
<Button type="button" onClick={handleSave} disabled={submitting} className="gap-2"> <Button type="button" onClick={handleSave} disabled={submitting} className="gap-2">
{submitting ? <Loader2Icon className="size-4 animate-spin" /> : <SaveIcon className="size-4" />} {submitting ? <Loader2Icon className="size-4 animate-spin" /> : <SaveIcon className="size-4" />}

View File

@ -7,12 +7,7 @@ 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 { import { buildRrule, DEFAULT_RECURRENCE, type RecurrenceSpec } from "@/lib/recurrence";
buildRrule,
describeRecurrence,
DEFAULT_RECURRENCE,
type RecurrenceSpec,
} from "@/lib/recurrence";
import { splitDateTime, validateScheduledAt } from "@/lib/date-picker"; import { splitDateTime, validateScheduledAt } from "@/lib/date-picker";
import { RecurrencePicker } from "@/components/recurrence-picker"; import { RecurrencePicker } from "@/components/recurrence-picker";
@ -124,8 +119,6 @@ export function WhenFormClient({
router.push(`/reminders/new?${sp.toString()}` as any); router.push(`/reminders/new?${sp.toString()}` as any);
} }
const previewSentence = describeRecurrence(spec, previewDt);
return ( return (
<div className="space-y-5"> <div className="space-y-5">
{/* Date + time */} {/* Date + time */}
@ -166,12 +159,6 @@ export function WhenFormClient({
<RecurrencePicker firstFire={previewDt} value={spec} onChange={setSpec} /> <RecurrencePicker firstFire={previewDt} value={spec} onChange={setSpec} />
{previewSentence && (
<p className="rounded-lg bg-primary/5 px-3 py-2 text-xs text-primary/80">
{previewSentence}
</p>
)}
{error && ( {error && (
<div className="flex items-center gap-1.5 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive"> <div className="flex items-center gap-1.5 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertCircleIcon className="size-3.5 shrink-0" /> <AlertCircleIcon className="size-3.5 shrink-0" />
@ -179,10 +166,6 @@ export function WhenFormClient({
</div> </div>
)} )}
<p className="text-xs text-muted-foreground">
Times are in <span className="font-medium">{timezone}</span>.
</p>
<div className="flex justify-end pt-1"> <div className="flex justify-end pt-1">
<Button type="button" onClick={handleContinue}> <Button type="button" onClick={handleContinue}>
Continue Continue