cm_whatsapp_bot_v1/apps/web/src/components/recurrence-picker.tsx
yiekheng 48cae84919 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>
2026-05-10 11:28:42 +08:00

588 lines
20 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { DateTime } from "luxon";
import { PlusIcon, RepeatIcon, Trash2Icon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import {
WEEKDAY_LABELS,
isoWeekdayToCron,
type RecurrenceSpec,
} from "@/lib/recurrence";
interface RecurrencePickerProps {
/**
* First fire — only used to seed defaults for newly-added rules
* (initial weekday, monthday, time). Each rule then carries its
* own hour/minute, so changing the date+time inputs above no
* longer reshapes existing rules.
*/
firstFire: DateTime;
value: RecurrenceSpec;
onChange: (next: RecurrenceSpec) => void;
}
// ---------------------------------------------------------------------------
// Per-row draft (one recurring rule)
// ---------------------------------------------------------------------------
type RuleType = "daily" | "weekly" | "monthly" | "yearly";
interface Draft {
type: RuleType;
/** Cron weekday list (0=Sun..6=Sat). */
weekdays: number[];
/** Sorted unique day-of-month list (1-31). Used by monthly + yearly. */
monthDays: 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). */
minute: number;
}
const MAX_RULES = 8;
function defaultDraft(firstFire: DateTime): Draft {
return {
type: "daily",
weekdays: [isoWeekdayToCron(firstFire.weekday)],
monthDays: [firstFire.day],
months: [firstFire.month],
hour: firstFire.hour,
minute: firstFire.minute,
};
}
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");
}
function parseHHMM(s: string): { hour: number; minute: number } | null {
const m = s.match(/^(\d{1,2}):(\d{2})$/);
if (!m) return null;
const h = Number(m[1]);
const min = Number(m[2]);
if (h < 0 || h > 23 || min < 0 || min > 59) return null;
return { hour: h, minute: min };
}
function clamp(n: number, lo: number, hi: number): number {
if (!Number.isFinite(n)) return lo;
return Math.min(Math.max(Math.floor(n), lo), hi);
}
const MONTH_NAMES = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
];
function draftToCron(d: Draft): string | null {
const m = clamp(d.minute, 0, 59);
const h = clamp(d.hour, 0, 23);
switch (d.type) {
case "daily":
return `${m} ${h} * * *`;
case "weekly":
if (!d.weekdays.length) return null;
return `${m} ${h} * * ${d.weekdays.slice().sort((a, b) => a - b).join(",")}`;
case "monthly": {
const days = uniqSortedDays(d.monthDays);
if (!days.length) return null;
return `${m} ${h} ${days.join(",")} * *`;
}
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(",")} *`;
}
}
}
function describeDraft(d: Draft): string {
const t = `${pad2(clamp(d.hour, 0, 23))}:${pad2(clamp(d.minute, 0, 59))}`;
switch (d.type) {
case "daily":
return `Every day at ${t}`;
case "weekly": {
if (!d.weekdays.length) return "Pick at least one weekday";
const labels = d.weekdays
.slice()
.sort((a, b) => a - b)
.map((c) => {
const iso = c === 0 ? 7 : c;
return WEEKDAY_LABELS[iso - 1]?.short ?? "";
})
.filter(Boolean)
.join(", ");
return `Every week on ${labels} 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": {
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}`;
}
}
}
/** Reverse-engineer a single cron expression into a Draft. Falls back to
* daily-every-day if the expression doesn't match a known shape. */
function draftFromCronExpr(expr: string, firstFire: DateTime): Draft {
const base = defaultDraft(firstFire);
// Pull MM HH off the front so each rule restores its own time.
const head = expr.match(/^(\d+)\s+(\d+)\s+(.*)$/);
if (!head) return base;
const minute = clamp(Number(head[1]), 0, 59);
const hour = clamp(Number(head[2]), 0, 23);
const rest = head[3]!.trim();
let m: RegExpMatchArray | null;
if (rest === "* * *") {
return { ...base, type: "daily", hour, minute };
}
// Any DOW list (including the legacy "1-5" weekday-only daily rule)
// round-trips as a Weekly draft.
if ((m = rest.match(/^\* \* ([0-9,\-]+)$/))) {
const days = m[1]!
.split(",")
.flatMap((p) => {
const r = p.match(/^(\d+)-(\d+)$/);
if (r) {
const out: number[] = [];
for (let i = Number(r[1]); i <= Number(r[2]); i++) out.push(i);
return out;
}
return [Number(p)];
})
.filter((n) => n >= 0 && n <= 6);
return { ...base, type: "weekly", weekdays: days, 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(/^([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",
monthDays: days.length ? days : [1],
months: months.length ? months : [1],
hour,
minute,
};
}
return { ...base, hour, minute };
}
/** Parse a (possibly multi-line) cron rule into an array of drafts. */
function draftsFromRule(rule: string | null | undefined, firstFire: DateTime): Draft[] {
if (!rule) return [];
const expr = rule.startsWith("CRON:") ? rule.slice(5) : rule;
const lines = expr.split("\n").map((s) => s.trim()).filter(Boolean);
if (lines.length === 0) return [];
return lines.map((line) => draftFromCronExpr(line, firstFire));
}
/** Compile an array of drafts to a single multi-line CRON: rule, or null
* if there are no rules (= one-off). */
function draftsToRule(drafts: Draft[]): string | null {
if (drafts.length === 0) return null;
const exprs = drafts
.map((d) => draftToCron(d))
.filter((s): s is string => Boolean(s));
if (exprs.length === 0) return null;
return exprs.join("\n");
}
// ---------------------------------------------------------------------------
// The picker
// ---------------------------------------------------------------------------
/**
* Inline recurrence picker. Lives in the When form right under the
* date+time inputs.
*
* Repeats
* ┌─────────────────────────────────────────────────────┐
* │ Schedule 1 [✕ remove] │
* │ [Daily] [Weekly] [Monthly] [Yearly] │
* │ <per-tab config> │
* ├─────────────────────────────────────────────────────┤
* │ Schedule 2 [✕ remove] │
* │ [Daily] [Weekly] [Monthly] [Yearly] │
* │ <per-tab config> │
* └─────────────────────────────────────────────────────┘
* [+ Add another schedule]
*
* Fires:
* • Every weekday at 09:00
* • Every Friday at 17:00
*
* Zero rules = "Don't repeat" (fires once at date+time and ends).
* Adding a rule emits a `{ kind: "cron", cron: "<line1>\n<line2>" }`
* spec; the bot's `nextOccurrence` already supports newline-joined
* cron and returns the earliest next fire across all rules.
*/
export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePickerProps) {
const [drafts, setDrafts] = useState<Draft[]>(() =>
draftsFromRule(value.kind === "cron" ? value.cron ?? null : null, firstFire),
);
// Compile drafts back to the parent value whenever they change. Each
// rule carries its own hour/minute now, so the date+time inputs above
// no longer drive cron output — they only set the very first fire.
useEffect(() => {
const rule = draftsToRule(drafts);
if (!rule) {
if (value.kind !== "none") {
onChange({ kind: "none", interval: 1, weeklyDays: [], end: { kind: "never" } });
}
return;
}
if (value.kind !== "cron" || value.cron !== rule) {
onChange({
kind: "cron",
interval: 1,
weeklyDays: [],
cron: rule,
end: { kind: "never" },
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [drafts]);
function updateDraft(idx: number, patch: Partial<Draft>) {
setDrafts((prev) => prev.map((d, i) => (i === idx ? { ...d, ...patch } : d)));
}
function addRule() {
if (drafts.length >= MAX_RULES) return;
setDrafts((prev) => [...prev, defaultDraft(firstFire)]);
}
function removeRule(idx: number) {
setDrafts((prev) => prev.filter((_, i) => i !== idx));
}
return (
<div className="space-y-2">
<Label className="flex items-center gap-1.5">
<RepeatIcon className="size-3.5" />
Repeats
</Label>
{drafts.length === 0 ? (
<div className="rounded-xl border border-border bg-muted/20 px-3 py-3 text-sm text-muted-foreground">
No Repeats
</div>
) : (
<div className="overflow-hidden rounded-xl border border-border bg-card">
{drafts.map((draft, idx) => (
<div
key={idx}
className={cn(
"p-3 space-y-3",
idx > 0 && "border-t border-border",
)}
>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium text-muted-foreground">
Schedule {idx + 1}
</span>
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 px-2 text-muted-foreground hover:text-destructive"
onClick={() => removeRule(idx)}
aria-label={`Remove schedule ${idx + 1}`}
>
<Trash2Icon className="size-3.5" />
</Button>
</div>
<RuleEditor
draft={draft}
onChange={(patch) => updateDraft(idx, patch)}
/>
<p className="rounded-lg bg-primary/5 px-2.5 py-1.5 text-xs text-primary/80">
{describeDraft(draft)}
</p>
</div>
))}
</div>
)}
<div>
<Button
type="button"
size="sm"
variant="outline"
onClick={addRule}
disabled={drafts.length >= MAX_RULES}
className="gap-1.5"
>
<PlusIcon className="size-3.5" />
{drafts.length === 0 ? "Add a recurring schedule" : "Add another schedule"}
</Button>
{drafts.length >= MAX_RULES && (
<span className="ml-2 text-xs text-muted-foreground">
Up to {MAX_RULES} schedules per reminder
</span>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Single-rule editor (tabs + per-type config)
// ---------------------------------------------------------------------------
interface RuleEditorProps {
draft: Draft;
onChange: (patch: Partial<Draft>) => void;
}
function RuleEditor({ draft, onChange }: RuleEditorProps) {
return (
<Tabs
value={draft.type}
onValueChange={(v) => onChange({ type: v as RuleType })}
>
<TabsList className="w-full">
<TabsTrigger value="daily">Daily</TabsTrigger>
<TabsTrigger value="weekly">Weekly</TabsTrigger>
<TabsTrigger value="monthly">Monthly</TabsTrigger>
<TabsTrigger value="yearly">Yearly</TabsTrigger>
</TabsList>
<TabsContent value="daily" className="space-y-3 pt-3">
<p className="text-xs text-muted-foreground">
Fires once a day at the time below.
</p>
<TimeField draft={draft} onChange={onChange} />
</TabsContent>
<TabsContent value="weekly" className="space-y-3 pt-3">
<div className="space-y-2">
<Label className="text-sm">On these days</Label>
<div className="flex flex-wrap gap-1.5">
{WEEKDAY_LABELS.map(({ iso, short }) => {
const cronDow = isoWeekdayToCron(iso);
const active = draft.weekdays.includes(cronDow);
return (
<button
key={iso}
type="button"
onClick={() =>
onChange({
weekdays: active
? draft.weekdays.filter((d) => d !== cronDow)
: [...draft.weekdays, cronDow].sort((a, b) => a - b),
})
}
aria-pressed={active}
className={cn(
"inline-flex h-8 min-w-12 items-center justify-center rounded-lg border px-2.5 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",
)}
>
{short}
</button>
);
})}
</div>
</div>
<TimeField draft={draft} onChange={onChange} />
</TabsContent>
<TabsContent value="monthly" className="space-y-3 pt-3">
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label className="text-sm">Days of the month</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">
Months without a selected day skip naturally (e.g. day 31 in February).
</p>
</div>
<TimeField draft={draft} onChange={onChange} />
</TabsContent>
<TabsContent value="yearly" className="space-y-3 pt-3">
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label className="text-sm">Months</Label>
<span className="text-xs text-muted-foreground">
{draft.months.length} selected
</span>
</div>
<div className="grid grid-cols-6 gap-1">
{MONTH_NAMES.map((name, i) => {
const month = i + 1;
const active = draft.months.includes(month);
return (
<button
key={month}
type="button"
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 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} />
</TabsContent>
</Tabs>
);
}
interface TimeFieldProps {
draft: Draft;
onChange: (patch: Partial<Draft>) => void;
}
/** Per-rule time picker (HH:MM). Drives the cron expression's MM HH columns. */
function TimeField({ draft, onChange }: TimeFieldProps) {
const value = `${pad2(clamp(draft.hour, 0, 23))}:${pad2(clamp(draft.minute, 0, 59))}`;
return (
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Fires at</Label>
<Input
type="time"
value={value}
onChange={(e) => {
const parsed = parseHHMM(e.target.value);
if (parsed) onChange(parsed);
}}
className="h-8 w-32"
/>
</div>
);
}