From 48cae849194172892992c89682345937dd5d41ba Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sun, 10 May 2026 11:28:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(recurrence):=20Yearly=20tab=20=E2=80=94=20?= =?UTF-8?q?month=20grid=20+=20day=20grid,=20both=20multi-select?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/web/src/components/recurrence-picker.tsx | 141 +++++++++++++----- 1 file changed, 104 insertions(+), 37 deletions(-) diff --git a/apps/web/src/components/recurrence-picker.tsx b/apps/web/src/components/recurrence-picker.tsx index 7fce4b6..c95fa98 100644 --- a/apps/web/src/components/recurrence-picker.tsx +++ b/apps/web/src/components/recurrence-picker.tsx @@ -36,11 +36,10 @@ interface Draft { type: RuleType; /** Cron weekday list (0=Sun..6=Sat). */ 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[]; - /** Single day-of-month (1-31) for yearly. */ - monthDay: number; - month: number; + /** Sorted unique month list (1-12). Used by yearly only. */ + months: number[]; /** Hour-of-day (0-23) for this rule's fire time. */ hour: number; /** Minute-of-hour (0-59). */ @@ -54,8 +53,7 @@ function defaultDraft(firstFire: DateTime): Draft { type: "daily", weekdays: [isoWeekdayToCron(firstFire.weekday)], monthDays: [firstFire.day], - monthDay: firstFire.day, - month: firstFire.month, + months: [firstFire.month], hour: firstFire.hour, 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); } +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 { return n.toString().padStart(2, "0"); } @@ -102,8 +104,12 @@ function draftToCron(d: Draft): string | null { if (!days.length) return null; return `${m} ${h} ${days.join(",")} * *`; } - case "yearly": - return `${m} ${h} ${clamp(d.monthDay, 1, 31)} ${clamp(d.month, 1, 12)} *`; + case "yearly": { + 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`; return `Every month on day${days.length > 1 ? "s" : ""} ${list} at ${t}`; } - case "yearly": - return `Every year on ${MONTH_NAMES[clamp(d.month, 1, 12) - 1]} ${clamp(d.monthDay, 1, 31)} at ${t}`; + case "yearly": { + 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, }; } - 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 { ...base, type: "yearly", - monthDay: Number(m[1]), - month: Number(m[2]), + monthDays: days.length ? days : [1], + months: months.length ? months : [1], hour, minute, }; @@ -461,33 +479,82 @@ function RuleEditor({ draft, onChange }: RuleEditorProps) { -
-
- - +
+
+ + + {draft.months.length} selected +
-
- - onChange({ monthDay: Number(e.target.value) || 1 })} - className="h-8 w-20" - /> +
+ {MONTH_NAMES.map((name, i) => { + const month = i + 1; + const active = draft.months.includes(month); + return ( + + ); + })}
+ +
+
+ + + {draft.monthDays.length} selected + +
+
+ {Array.from({ length: 31 }, (_, i) => i + 1).map((day) => { + const active = draft.monthDays.includes(day); + return ( + + ); + })} +
+

+ Fires on every selected day of every selected month. +

+
+