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. +

+
+