feat(recurrence): Yearly tab — month grid + day grid, both multi-select

Yearly was a single Month dropdown + a Day number input — one Month and
one Day per rule. That meant "every quarter on the 1st" needed four
separate schedule rows.

Now Yearly mirrors Monthly's grid pattern but with two grids:

  Months   [Jan][Feb][Mar][Apr][May][Jun]
           [Jul][Aug][Sep][Oct][Nov][Dec]

  Days     [ 1][ 2][ 3]...[31]   (7×5 grid)

Both grids are multi-select. Cron output uses the comma-list form on
both DOM and month positions:

  months: [1,4,7,10] + days: [1]   →   "0 9 1 1,4,7,10 *"
  months: [12]       + days: [24,25,31] → "0 9 24,25,31 12 *"

The cron field is a Cartesian product — every selected day fires in
every selected month. So "every quarter on the 1st" is now one rule.

Round-trip: parser accepts comma-lists for both DOM and month, with
single-element shapes (the old "0 9 13 5 *") still loading fine.

Migration of saved data: old yearly rules with one DOM + one month
parse into monthDays=[X], months=[Y] — identical visual selection in
the new grid, identical cron output. No DB changes needed.

Renamed `Draft.month` to `Draft.months: number[]`. The "Single
day-of-month for yearly" field is gone — yearly now reads
`monthDays` (same as monthly).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-05-10 11:28:42 +08:00
parent b8f60bdaf3
commit 48cae84919

View File

@ -36,11 +36,10 @@ interface Draft {
type: RuleType; type: RuleType;
/** Cron weekday list (0=Sun..6=Sat). */ /** Cron weekday list (0=Sun..6=Sat). */
weekdays: number[]; weekdays: number[];
/** Sorted unique day-of-month list (1-31) for monthly. */ /** Sorted unique day-of-month list (1-31). Used by monthly + yearly. */
monthDays: number[]; monthDays: number[];
/** Single day-of-month (1-31) for yearly. */ /** Sorted unique month list (1-12). Used by yearly only. */
monthDay: number; months: number[];
month: number;
/** Hour-of-day (0-23) for this rule's fire time. */ /** Hour-of-day (0-23) for this rule's fire time. */
hour: number; hour: number;
/** Minute-of-hour (0-59). */ /** Minute-of-hour (0-59). */
@ -54,8 +53,7 @@ function defaultDraft(firstFire: DateTime): Draft {
type: "daily", type: "daily",
weekdays: [isoWeekdayToCron(firstFire.weekday)], weekdays: [isoWeekdayToCron(firstFire.weekday)],
monthDays: [firstFire.day], monthDays: [firstFire.day],
monthDay: firstFire.day, months: [firstFire.month],
month: firstFire.month,
hour: firstFire.hour, hour: firstFire.hour,
minute: firstFire.minute, minute: firstFire.minute,
}; };
@ -65,6 +63,10 @@ function uniqSortedDays(days: number[]): number[] {
return Array.from(new Set(days.filter((d) => d >= 1 && d <= 31))).sort((a, b) => a - b); return Array.from(new Set(days.filter((d) => d >= 1 && d <= 31))).sort((a, b) => a - b);
} }
function uniqSortedMonths(months: number[]): number[] {
return Array.from(new Set(months.filter((m) => m >= 1 && m <= 12))).sort((a, b) => a - b);
}
function pad2(n: number): string { function pad2(n: number): string {
return n.toString().padStart(2, "0"); return n.toString().padStart(2, "0");
} }
@ -102,8 +104,12 @@ function draftToCron(d: Draft): string | null {
if (!days.length) return null; if (!days.length) return null;
return `${m} ${h} ${days.join(",")} * *`; return `${m} ${h} ${days.join(",")} * *`;
} }
case "yearly": case "yearly": {
return `${m} ${h} ${clamp(d.monthDay, 1, 31)} ${clamp(d.month, 1, 12)} *`; const days = uniqSortedDays(d.monthDays);
const months = uniqSortedMonths(d.months);
if (!days.length || !months.length) return null;
return `${m} ${h} ${days.join(",")} ${months.join(",")} *`;
}
} }
} }
@ -134,8 +140,18 @@ function describeDraft(d: Draft): string {
: `${days.slice(0, 6).join(", ")} +${days.length - 6} more`; : `${days.slice(0, 6).join(", ")} +${days.length - 6} more`;
return `Every month on day${days.length > 1 ? "s" : ""} ${list} at ${t}`; return `Every month on day${days.length > 1 ? "s" : ""} ${list} at ${t}`;
} }
case "yearly": case "yearly": {
return `Every year on ${MONTH_NAMES[clamp(d.month, 1, 12) - 1]} ${clamp(d.monthDay, 1, 31)} at ${t}`; const days = uniqSortedDays(d.monthDays);
const months = uniqSortedMonths(d.months);
if (!days.length) return "Pick at least one day";
if (!months.length) return "Pick at least one month";
const monthLabel = months.map((mo) => MONTH_NAMES[mo - 1]?.slice(0, 3)).join(", ");
const dayLabel =
days.length <= 6
? days.join(", ")
: `${days.slice(0, 6).join(", ")} +${days.length - 6} more`;
return `Every year in ${monthLabel} on day${days.length > 1 ? "s" : ""} ${dayLabel} at ${t}`;
}
} }
} }
@ -181,12 +197,14 @@ function draftFromCronExpr(expr: string, firstFire: DateTime): Draft {
minute, minute,
}; };
} }
if ((m = rest.match(/^(\d+) (\d+) \*$/))) { if ((m = rest.match(/^([0-9,]+) ([0-9,]+) \*$/))) {
const days = uniqSortedDays(m[1]!.split(",").map((s) => Number(s)));
const months = uniqSortedMonths(m[2]!.split(",").map((s) => Number(s)));
return { return {
...base, ...base,
type: "yearly", type: "yearly",
monthDay: Number(m[1]), monthDays: days.length ? days : [1],
month: Number(m[2]), months: months.length ? months : [1],
hour, hour,
minute, minute,
}; };
@ -461,33 +479,82 @@ function RuleEditor({ draft, onChange }: RuleEditorProps) {
</TabsContent> </TabsContent>
<TabsContent value="yearly" className="space-y-3 pt-3"> <TabsContent value="yearly" className="space-y-3 pt-3">
<div className="flex flex-wrap items-end gap-3"> <div className="space-y-2">
<div className="space-y-1"> <div className="flex items-center justify-between gap-2">
<Label className="text-xs text-muted-foreground">Month</Label> <Label className="text-sm">Months</Label>
<select <span className="text-xs text-muted-foreground">
value={draft.month} {draft.months.length} selected
onChange={(e) => onChange({ month: Number(e.target.value) })} </span>
className="h-8 rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{MONTH_NAMES.map((name, i) => (
<option key={name} value={i + 1}>
{name}
</option>
))}
</select>
</div> </div>
<div className="space-y-1"> <div className="grid grid-cols-6 gap-1">
<Label className="text-xs text-muted-foreground">Day</Label> {MONTH_NAMES.map((name, i) => {
<Input const month = i + 1;
type="number" const active = draft.months.includes(month);
min={1} return (
max={31} <button
value={draft.monthDay} key={month}
onChange={(e) => onChange({ monthDay: Number(e.target.value) || 1 })} type="button"
className="h-8 w-20" onClick={() =>
/> onChange({
months: active
? draft.months.filter((mo) => mo !== month)
: uniqSortedMonths([...draft.months, month]),
})
}
aria-pressed={active}
className={cn(
"inline-flex h-8 items-center justify-center rounded-md border 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",
)}
>
{name.slice(0, 3)}
</button>
);
})}
</div> </div>
</div> </div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label className="text-sm">Days</Label>
<span className="text-xs text-muted-foreground">
{draft.monthDays.length} selected
</span>
</div>
<div className="grid grid-cols-7 gap-1">
{Array.from({ length: 31 }, (_, i) => i + 1).map((day) => {
const active = draft.monthDays.includes(day);
return (
<button
key={day}
type="button"
onClick={() =>
onChange({
monthDays: active
? draft.monthDays.filter((d) => d !== day)
: uniqSortedDays([...draft.monthDays, day]),
})
}
aria-pressed={active}
className={cn(
"inline-flex h-8 items-center justify-center rounded-md border 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",
)}
>
{day}
</button>
);
})}
</div>
<p className="text-xs text-muted-foreground">
Fires on every selected day of every selected month.
</p>
</div>
<TimeField draft={draft} onChange={onChange} /> <TimeField draft={draft} onChange={onChange} />
</TabsContent> </TabsContent>
</Tabs> </Tabs>