diff --git a/apps/web/src/components/recurrence-picker.tsx b/apps/web/src/components/recurrence-picker.tsx index b9e6a3f..7fce4b6 100644 --- a/apps/web/src/components/recurrence-picker.tsx +++ b/apps/web/src/components/recurrence-picker.tsx @@ -36,6 +36,9 @@ interface Draft { type: RuleType; /** Cron weekday list (0=Sun..6=Sat). */ weekdays: number[]; + /** Sorted unique day-of-month list (1-31) for monthly. */ + monthDays: number[]; + /** Single day-of-month (1-31) for yearly. */ monthDay: number; month: number; /** Hour-of-day (0-23) for this rule's fire time. */ @@ -50,6 +53,7 @@ function defaultDraft(firstFire: DateTime): Draft { return { type: "daily", weekdays: [isoWeekdayToCron(firstFire.weekday)], + monthDays: [firstFire.day], monthDay: firstFire.day, month: firstFire.month, hour: firstFire.hour, @@ -57,6 +61,10 @@ function defaultDraft(firstFire: DateTime): Draft { }; } +function uniqSortedDays(days: number[]): number[] { + return Array.from(new Set(days.filter((d) => d >= 1 && d <= 31))).sort((a, b) => a - b); +} + function pad2(n: number): string { return n.toString().padStart(2, "0"); } @@ -89,8 +97,11 @@ function draftToCron(d: Draft): string | null { case "weekly": if (!d.weekdays.length) return null; return `${m} ${h} * * ${d.weekdays.slice().sort((a, b) => a - b).join(",")}`; - case "monthly": - return `${m} ${h} ${clamp(d.monthDay, 1, 31)} * *`; + case "monthly": { + const days = uniqSortedDays(d.monthDays); + 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)} *`; } @@ -114,8 +125,15 @@ function describeDraft(d: Draft): string { .join(", "); return `Every week on ${labels} at ${t}`; } - case "monthly": - return `Every month on day ${clamp(d.monthDay, 1, 31)} at ${t}`; + case "monthly": { + const days = uniqSortedDays(d.monthDays); + if (!days.length) return "Pick at least one day"; + const list = + days.length <= 6 + ? days.join(", ") + : `${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}`; } @@ -153,8 +171,15 @@ function draftFromCronExpr(expr: string, firstFire: DateTime): Draft { .filter((n) => n >= 0 && n <= 6); return { ...base, type: "weekly", weekdays: days, hour, minute }; } - if ((m = rest.match(/^(\d+) \* \*$/))) { - return { ...base, type: "monthly", monthDay: Number(m[1]), hour, minute }; + if ((m = rest.match(/^([0-9,]+) \* \*$/))) { + const days = uniqSortedDays(m[1]!.split(",").map((s) => Number(s))); + return { + ...base, + type: "monthly", + monthDays: days.length ? days : [1], + hour, + minute, + }; } if ((m = rest.match(/^(\d+) (\d+) \*$/))) { return { @@ -266,7 +291,7 @@ export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePicke {drafts.length === 0 ? (
- Doesn't repeat — fires once at the date and time above. + No Repeats
) : (
@@ -395,20 +420,42 @@ function RuleEditor({ draft, onChange }: RuleEditorProps) {
- -
- onChange({ monthDay: Number(e.target.value) || 1 })} - className="h-8 w-24" - /> +
+ - Months without this day skip naturally (e.g. 31st) + {draft.monthDays.length} selected
+
+ {Array.from({ length: 31 }, (_, i) => i + 1).map((day) => { + const active = draft.monthDays.includes(day); + return ( + + ); + })} +
+

+ Months without a selected day skip naturally (e.g. day 31 in February). +