feat(recurrence): Monthly multi-date grid; rename empty state to "No Repeats"
Monthly tab is now a 7-column grid of buttons for days 1-31. Tap any combination to fire the reminder on those days every month — picks multiple. The summary line reads, e.g.: "Every month on days 1, 15 at 09:00" Days that don't exist in some months (29-31) skip naturally — that's just how the cron DOM field works, no extra plumbing needed. Cron output uses the comma-list form: Selected days [1, 15, 28] → "0 9 1,15,28 * *" The parser now accepts a comma-separated DOM list on the way back in, so the picker round-trips a saved monthly rule with all selected days restored. Pre-existing single-day monthly rules (e.g. "0 9 15 * *") still load fine — the same regex handles both. Empty-state copy: rewrote the verbose "Doesn't repeat — fires once at the date and time above." down to just "No Repeats". The label "Repeats" above the box plus the "Add a recurring schedule" button below already explain the behaviour; the long sentence was pure noise. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
657fa71bf9
commit
b8f60bdaf3
@ -36,6 +36,9 @@ 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. */
|
||||||
|
monthDays: number[];
|
||||||
|
/** Single day-of-month (1-31) for yearly. */
|
||||||
monthDay: number;
|
monthDay: number;
|
||||||
month: 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. */
|
||||||
@ -50,6 +53,7 @@ function defaultDraft(firstFire: DateTime): Draft {
|
|||||||
return {
|
return {
|
||||||
type: "daily",
|
type: "daily",
|
||||||
weekdays: [isoWeekdayToCron(firstFire.weekday)],
|
weekdays: [isoWeekdayToCron(firstFire.weekday)],
|
||||||
|
monthDays: [firstFire.day],
|
||||||
monthDay: firstFire.day,
|
monthDay: firstFire.day,
|
||||||
month: firstFire.month,
|
month: firstFire.month,
|
||||||
hour: firstFire.hour,
|
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 {
|
function pad2(n: number): string {
|
||||||
return n.toString().padStart(2, "0");
|
return n.toString().padStart(2, "0");
|
||||||
}
|
}
|
||||||
@ -89,8 +97,11 @@ function draftToCron(d: Draft): string | null {
|
|||||||
case "weekly":
|
case "weekly":
|
||||||
if (!d.weekdays.length) return null;
|
if (!d.weekdays.length) return null;
|
||||||
return `${m} ${h} * * ${d.weekdays.slice().sort((a, b) => a - b).join(",")}`;
|
return `${m} ${h} * * ${d.weekdays.slice().sort((a, b) => a - b).join(",")}`;
|
||||||
case "monthly":
|
case "monthly": {
|
||||||
return `${m} ${h} ${clamp(d.monthDay, 1, 31)} * *`;
|
const days = uniqSortedDays(d.monthDays);
|
||||||
|
if (!days.length) return null;
|
||||||
|
return `${m} ${h} ${days.join(",")} * *`;
|
||||||
|
}
|
||||||
case "yearly":
|
case "yearly":
|
||||||
return `${m} ${h} ${clamp(d.monthDay, 1, 31)} ${clamp(d.month, 1, 12)} *`;
|
return `${m} ${h} ${clamp(d.monthDay, 1, 31)} ${clamp(d.month, 1, 12)} *`;
|
||||||
}
|
}
|
||||||
@ -114,8 +125,15 @@ function describeDraft(d: Draft): string {
|
|||||||
.join(", ");
|
.join(", ");
|
||||||
return `Every week on ${labels} at ${t}`;
|
return `Every week on ${labels} at ${t}`;
|
||||||
}
|
}
|
||||||
case "monthly":
|
case "monthly": {
|
||||||
return `Every month on day ${clamp(d.monthDay, 1, 31)} at ${t}`;
|
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":
|
case "yearly":
|
||||||
return `Every year on ${MONTH_NAMES[clamp(d.month, 1, 12) - 1]} ${clamp(d.monthDay, 1, 31)} at ${t}`;
|
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);
|
.filter((n) => n >= 0 && n <= 6);
|
||||||
return { ...base, type: "weekly", weekdays: days, hour, minute };
|
return { ...base, type: "weekly", weekdays: days, hour, minute };
|
||||||
}
|
}
|
||||||
if ((m = rest.match(/^(\d+) \* \*$/))) {
|
if ((m = rest.match(/^([0-9,]+) \* \*$/))) {
|
||||||
return { ...base, type: "monthly", monthDay: Number(m[1]), hour, minute };
|
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+) \*$/))) {
|
if ((m = rest.match(/^(\d+) (\d+) \*$/))) {
|
||||||
return {
|
return {
|
||||||
@ -266,7 +291,7 @@ export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePicke
|
|||||||
|
|
||||||
{drafts.length === 0 ? (
|
{drafts.length === 0 ? (
|
||||||
<div className="rounded-xl border border-border bg-muted/20 px-3 py-3 text-sm text-muted-foreground">
|
<div className="rounded-xl border border-border bg-muted/20 px-3 py-3 text-sm text-muted-foreground">
|
||||||
Doesn't repeat — fires once at the date and time above.
|
No Repeats
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-hidden rounded-xl border border-border bg-card">
|
<div className="overflow-hidden rounded-xl border border-border bg-card">
|
||||||
@ -395,20 +420,42 @@ function RuleEditor({ draft, onChange }: RuleEditorProps) {
|
|||||||
|
|
||||||
<TabsContent value="monthly" className="space-y-3 pt-3">
|
<TabsContent value="monthly" className="space-y-3 pt-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-sm">Day of the month</Label>
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<Label className="text-sm">Days of the month</Label>
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={31}
|
|
||||||
value={draft.monthDay}
|
|
||||||
onChange={(e) => onChange({ monthDay: Number(e.target.value) || 1 })}
|
|
||||||
className="h-8 w-24"
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
Months without this day skip naturally (e.g. 31st)
|
{draft.monthDays.length} selected
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
||||||
|
Months without a selected day skip naturally (e.g. day 31 in February).
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<TimeField draft={draft} onChange={onChange} />
|
<TimeField draft={draft} onChange={onChange} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user