feat(recurrence): redesign Repeats picker as a preset radio list
The old picker was a row of 5 frequency pills (One-off / Daily / Weekly /
Monthly / Yearly) followed by a separate detail panel — common cases
needed several clicks (interval, weekday list, etc.) and the visual
hierarchy didn't show what was selected at a glance.
New design — a vertical radio list with seven first-fire-aware presets
plus a Custom… expander:
○ Don't repeat (one-off)
○ Every day
○ Every weekday (Mon – Fri)
○ Every weekend (Sat – Sun)
○ Every week on Wed (matches start)
○ Every month on day 13 (matches start)
○ Every year on May 13 (matches start)
○ Custom… ▼ expands
Custom… reveals the existing power-user controls (frequency dropdown,
interval input, weekday picker, day-of-month, end-condition) without
crowding the common path. Toggling between presets and custom is
lossless — the spec is the source of truth.
New helpers in `lib/recurrence.ts`:
- `presetToSpec(id, firstFire)` — canonical RecurrenceSpec for each
preset (round-trippable).
- `matchPreset(spec, firstFire)` — reverse mapping; returns "custom"
for anything that doesn't fit a shortcut, so the picker auto-flips
into expanded mode for non-preset specs.
- `presetDescriptors(firstFire)` — list of preset id/label/hint with
first-fire-aware copy ("Every week on Wed", "May 13", etc).
Wired into both:
- reminder-wizard/when-form-client.tsx (creating)
- reminder-edit/edit-when-form.tsx (editing a section in place)
Tests (+4, 134 web + 26 bot = 160 total green):
- recurrence.test.ts gains a "preset shortcuts" suite covering:
* presetToSpec → canonical spec for each id
* round-trip via matchPreset
* matchPreset returns "custom" for non-shortcut specs
(interval > 1, weekly Mon/Wed/Fri, end=after, monthly on a
different day-of-month than the first fire)
* presetDescriptors labels are first-fire-aware
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
50df7fcb11
commit
991ff5fb22
366
apps/web/src/components/recurrence-picker.tsx
Normal file
366
apps/web/src/components/recurrence-picker.tsx
Normal file
@ -0,0 +1,366 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { DateTime } from "luxon";
|
||||||
|
import {
|
||||||
|
CheckIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronUpIcon,
|
||||||
|
RepeatIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
WEEKDAY_LABELS,
|
||||||
|
matchPreset,
|
||||||
|
presetDescriptors,
|
||||||
|
presetToSpec,
|
||||||
|
type EndKind,
|
||||||
|
type PresetId,
|
||||||
|
type RecurrenceKind,
|
||||||
|
type RecurrenceSpec,
|
||||||
|
} from "@/lib/recurrence";
|
||||||
|
|
||||||
|
interface RecurrencePickerProps {
|
||||||
|
/** First fire of the reminder — drives preset labels (e.g. "Every week on Wed"). */
|
||||||
|
firstFire: DateTime;
|
||||||
|
value: RecurrenceSpec;
|
||||||
|
onChange: (next: RecurrenceSpec) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FREQ_UNIT: Record<Exclude<RecurrenceKind, "none">, string> = {
|
||||||
|
daily: "day",
|
||||||
|
weekly: "week",
|
||||||
|
monthly: "month",
|
||||||
|
yearly: "year",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reminder repeat picker.
|
||||||
|
*
|
||||||
|
* ┌──────────────────────────────────────────────────────┐
|
||||||
|
* │ ○ Don't repeat (one-off) │
|
||||||
|
* │ ○ Every day │
|
||||||
|
* │ ○ Every weekday Mon – Fri │
|
||||||
|
* │ ○ Every weekend Sat – Sun │
|
||||||
|
* │ ○ Every week on Wed Same weekday as start │
|
||||||
|
* │ ○ Every month on day 13 … │
|
||||||
|
* │ ○ Every year on May 13 │
|
||||||
|
* │ ○ Custom… (expands the full power-user controls) │
|
||||||
|
* └──────────────────────────────────────────────────────┘
|
||||||
|
*
|
||||||
|
* Selecting a preset overwrites the spec to the canonical preset value.
|
||||||
|
* "Custom…" reveals interval / weekday picker / day-of-month / end
|
||||||
|
* controls so the user can build any RRULE we support.
|
||||||
|
*/
|
||||||
|
export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePickerProps) {
|
||||||
|
const activePreset = matchPreset(value, firstFire);
|
||||||
|
// The user can also expand the custom panel manually even when the
|
||||||
|
// current spec matches a preset — useful for "I want every 2 weeks
|
||||||
|
// on Mon, Wed". Default to expanded whenever the spec is custom.
|
||||||
|
const [forceCustomOpen, setForceCustomOpen] = useState(false);
|
||||||
|
const customExpanded = activePreset === "custom" || forceCustomOpen;
|
||||||
|
|
||||||
|
function pickPreset(id: PresetId) {
|
||||||
|
if (id === "custom") {
|
||||||
|
setForceCustomOpen(true);
|
||||||
|
// If the spec is currently a preset, seed the custom panel with
|
||||||
|
// its canonical form so editing starts from a sensible baseline.
|
||||||
|
if (activePreset !== "custom") {
|
||||||
|
onChange(presetToSpec("weekly_same", firstFire));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setForceCustomOpen(false);
|
||||||
|
onChange(presetToSpec(id, firstFire));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="flex items-center gap-1.5">
|
||||||
|
<RepeatIcon className="size-3.5" />
|
||||||
|
Repeats
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-xl border border-border bg-card">
|
||||||
|
<ul className="divide-y divide-border" role="radiogroup" aria-label="Repeat schedule">
|
||||||
|
{presetDescriptors(firstFire).map((p) => {
|
||||||
|
const selected = activePreset === p.id;
|
||||||
|
return (
|
||||||
|
<li key={p.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={selected}
|
||||||
|
onClick={() => pickPreset(p.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-3 px-4 py-3 text-left transition-colors",
|
||||||
|
selected
|
||||||
|
? "bg-primary/5 text-foreground"
|
||||||
|
: "hover:bg-muted text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Radio dot */}
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cn(
|
||||||
|
"flex size-4 shrink-0 items-center justify-center rounded-full border transition-colors",
|
||||||
|
selected
|
||||||
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
|
: "border-input bg-background",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{selected && <CheckIcon className="size-2.5" strokeWidth={3.5} />}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block text-sm font-medium leading-snug">{p.label}</span>
|
||||||
|
{p.hint && (
|
||||||
|
<span className="block text-xs text-muted-foreground">{p.hint}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{p.id === "custom" && (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{customExpanded ? (
|
||||||
|
<ChevronUpIcon className="size-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronDownIcon className="size-4" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{customExpanded && (
|
||||||
|
<CustomPanel firstFire={firstFire} value={value} onChange={onChange} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CustomPanelProps {
|
||||||
|
firstFire: DateTime;
|
||||||
|
value: RecurrenceSpec;
|
||||||
|
onChange: (next: RecurrenceSpec) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CustomPanel({ firstFire, value, onChange }: CustomPanelProps) {
|
||||||
|
const kind = value.kind === "none" ? "weekly" : value.kind;
|
||||||
|
|
||||||
|
function setKind(k: RecurrenceKind) {
|
||||||
|
if (k === "none") {
|
||||||
|
onChange(presetToSpec("none", firstFire));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChange({
|
||||||
|
...value,
|
||||||
|
kind: k,
|
||||||
|
// Seed weeklyDays with the first-fire weekday when flipping into
|
||||||
|
// weekly so the spec is concrete.
|
||||||
|
weeklyDays:
|
||||||
|
k === "weekly" && value.weeklyDays.length === 0
|
||||||
|
? [firstFire.weekday]
|
||||||
|
: value.weeklyDays,
|
||||||
|
monthDay:
|
||||||
|
k === "monthly" && value.monthDay === undefined
|
||||||
|
? firstFire.day
|
||||||
|
: value.monthDay,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setInterval(n: number) {
|
||||||
|
const safe = Number.isFinite(n) && n >= 1 ? Math.floor(n) : 1;
|
||||||
|
onChange({ ...value, interval: safe });
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleWeekday(iso: number) {
|
||||||
|
const next = value.weeklyDays.includes(iso)
|
||||||
|
? value.weeklyDays.filter((d) => d !== iso)
|
||||||
|
: [...value.weeklyDays, iso].sort((a, b) => a - b);
|
||||||
|
onChange({ ...value, weeklyDays: next });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMonthDay(n: number | "") {
|
||||||
|
if (n === "") {
|
||||||
|
onChange({ ...value, monthDay: undefined });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (n >= 1 && n <= 31) onChange({ ...value, monthDay: n });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setEndKind(k: EndKind) {
|
||||||
|
if (k === "never") onChange({ ...value, end: { kind: "never" } });
|
||||||
|
else if (k === "after")
|
||||||
|
onChange({
|
||||||
|
...value,
|
||||||
|
end: { kind: "after", count: value.end.kind === "after" ? value.end.count : 10 },
|
||||||
|
});
|
||||||
|
else
|
||||||
|
onChange({
|
||||||
|
...value,
|
||||||
|
end: { kind: "on", until: value.end.kind === "on" ? value.end.until : "" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 border-t border-border bg-muted/20 p-4">
|
||||||
|
{/* Frequency */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="custom-freq" className="text-xs text-muted-foreground">
|
||||||
|
Frequency
|
||||||
|
</Label>
|
||||||
|
<select
|
||||||
|
id="custom-freq"
|
||||||
|
value={kind}
|
||||||
|
onChange={(e) => setKind(e.target.value as RecurrenceKind)}
|
||||||
|
className="h-8 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<option value="daily">Daily</option>
|
||||||
|
<option value="weekly">Weekly</option>
|
||||||
|
<option value="monthly">Monthly</option>
|
||||||
|
<option value="yearly">Yearly</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Interval */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Label htmlFor="custom-interval" className="text-sm">
|
||||||
|
Every
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="custom-interval"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={999}
|
||||||
|
value={value.interval}
|
||||||
|
onChange={(e) => setInterval(Number(e.target.value))}
|
||||||
|
className="h-8 w-20"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{FREQ_UNIT[kind]}
|
||||||
|
{value.interval === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Weekly day picker */}
|
||||||
|
{kind === "weekly" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs text-muted-foreground">On these days</Label>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{WEEKDAY_LABELS.map(({ iso, short }) => {
|
||||||
|
const active = value.weeklyDays.includes(iso);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={iso}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleWeekday(iso)}
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Monthly day-of-month */}
|
||||||
|
{kind === "monthly" && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="custom-monthday" className="text-xs text-muted-foreground">
|
||||||
|
Day of the month
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="custom-monthday"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={31}
|
||||||
|
value={value.monthDay ?? ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
if (v === "") setMonthDay("");
|
||||||
|
else setMonthDay(Number(v));
|
||||||
|
}}
|
||||||
|
placeholder={String(firstFire.day)}
|
||||||
|
className="h-8 w-24"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Months without this day skip naturally (e.g. 31st).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* End condition */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs text-muted-foreground">Ends</Label>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{(["never", "after", "on"] as const).map((v) => {
|
||||||
|
const active = value.end.kind === v;
|
||||||
|
const label = v === "never" ? "Never" : v === "after" ? "After…" : "On date…";
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={v}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEndKind(v)}
|
||||||
|
aria-pressed={active}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center rounded-full border px-3 py-1 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",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{value.end.kind === "after" && (
|
||||||
|
<div className="flex items-center gap-2 pt-1">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={9999}
|
||||||
|
value={value.end.count}
|
||||||
|
onChange={(e) => {
|
||||||
|
const n = Number(e.target.value);
|
||||||
|
onChange({
|
||||||
|
...value,
|
||||||
|
end: { kind: "after", count: Number.isFinite(n) && n >= 1 ? n : 1 },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="h-8 w-24"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
occurrence{value.end.count === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{value.end.kind === "on" && (
|
||||||
|
<div className="pt-1">
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={value.end.until}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({ ...value, end: { kind: "on", until: e.target.value } })
|
||||||
|
}
|
||||||
|
className="h-8 w-44"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -8,22 +8,18 @@ import {
|
|||||||
CalendarIcon,
|
CalendarIcon,
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
Loader2Icon,
|
Loader2Icon,
|
||||||
RepeatIcon,
|
|
||||||
SaveIcon,
|
SaveIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { splitDateTime, validateScheduledAt } from "@/lib/date-picker";
|
import { splitDateTime, validateScheduledAt } from "@/lib/date-picker";
|
||||||
import {
|
import {
|
||||||
WEEKDAY_LABELS,
|
|
||||||
buildRrule,
|
buildRrule,
|
||||||
describeRecurrence,
|
describeRecurrence,
|
||||||
type EndKind,
|
|
||||||
type RecurrenceKind,
|
|
||||||
type RecurrenceSpec,
|
type RecurrenceSpec,
|
||||||
} from "@/lib/recurrence";
|
} from "@/lib/recurrence";
|
||||||
|
import { RecurrencePicker } from "@/components/recurrence-picker";
|
||||||
import { updateReminderAction } from "@/actions/reminders";
|
import { updateReminderAction } from "@/actions/reminders";
|
||||||
|
|
||||||
interface EditWhenFormProps {
|
interface EditWhenFormProps {
|
||||||
@ -38,20 +34,6 @@ interface EditWhenFormProps {
|
|||||||
timezone: string;
|
timezone: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const KINDS: Array<{ value: RecurrenceKind; label: string }> = [
|
|
||||||
{ value: "none", label: "One-off" },
|
|
||||||
{ value: "daily", label: "Daily" },
|
|
||||||
{ value: "weekly", label: "Weekly" },
|
|
||||||
{ value: "monthly", label: "Monthly" },
|
|
||||||
{ value: "yearly", label: "Yearly" },
|
|
||||||
];
|
|
||||||
const FREQ_UNIT: Record<Exclude<RecurrenceKind, "none">, string> = {
|
|
||||||
daily: "day",
|
|
||||||
weekly: "week",
|
|
||||||
monthly: "month",
|
|
||||||
yearly: "year",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function EditWhenForm({
|
export function EditWhenForm({
|
||||||
reminderId,
|
reminderId,
|
||||||
accountId,
|
accountId,
|
||||||
@ -68,45 +50,17 @@ export function EditWhenForm({
|
|||||||
|
|
||||||
const [date, setDate] = useState(initial.date);
|
const [date, setDate] = useState(initial.date);
|
||||||
const [time, setTime] = useState(initial.time);
|
const [time, setTime] = useState(initial.time);
|
||||||
const [kind, setKind] = useState<RecurrenceKind>(initialSpec.kind);
|
const [spec, setSpec] = useState<RecurrenceSpec>(initialSpec);
|
||||||
const [interval, setIntervalValue] = useState<number>(initialSpec.interval);
|
|
||||||
const [weeklyDays, setWeeklyDays] = useState<number[]>(initialSpec.weeklyDays);
|
|
||||||
const [monthDay, setMonthDay] = useState<number | "">(initialSpec.monthDay ?? "");
|
|
||||||
const [endKind, setEndKind] = useState<EndKind>(initialSpec.end.kind);
|
|
||||||
const [endCount, setEndCount] = useState<number>(
|
|
||||||
initialSpec.end.kind === "after" ? initialSpec.end.count : 10,
|
|
||||||
);
|
|
||||||
const [endUntil, setEndUntil] = useState<string>(
|
|
||||||
initialSpec.end.kind === "on" ? initialSpec.end.until : "",
|
|
||||||
);
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
function toggleWeekday(iso: number) {
|
const previewDt = (() => {
|
||||||
setWeeklyDays((prev) =>
|
if (date && time) {
|
||||||
prev.includes(iso) ? prev.filter((d) => d !== iso) : [...prev, iso].sort((a, b) => a - b),
|
const d = DateTime.fromFormat(`${date} ${time}`, "yyyy-MM-dd HH:mm", { zone: timezone });
|
||||||
);
|
if (d.isValid) return d;
|
||||||
}
|
|
||||||
|
|
||||||
function buildSpec(firstFire: DateTime): RecurrenceSpec {
|
|
||||||
const safeMonthDay =
|
|
||||||
typeof monthDay === "number" && monthDay >= 1 && monthDay <= 31
|
|
||||||
? monthDay
|
|
||||||
: firstFire.day;
|
|
||||||
let end: RecurrenceSpec["end"] = { kind: "never" };
|
|
||||||
if (endKind === "after" && endCount > 0) {
|
|
||||||
end = { kind: "after", count: Math.floor(endCount) };
|
|
||||||
} else if (endKind === "on" && endUntil) {
|
|
||||||
end = { kind: "on", until: endUntil };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
kind,
|
|
||||||
interval: Math.max(1, Math.floor(interval || 1)),
|
|
||||||
weeklyDays,
|
|
||||||
monthDay: kind === "monthly" ? safeMonthDay : undefined,
|
|
||||||
end,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
return DateTime.fromISO(initialIso, { zone: timezone });
|
||||||
|
})();
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
const v = validateScheduledAt(date, time, timezone, Date.now());
|
const v = validateScheduledAt(date, time, timezone, Date.now());
|
||||||
@ -119,12 +73,11 @@ export function EditWhenForm({
|
|||||||
setError(map[v.reason]);
|
setError(map[v.reason]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (endKind === "on" && !endUntil) {
|
if (spec.end.kind === "on" && !spec.end.until) {
|
||||||
setError("Pick the end date for this recurrence.");
|
setError("Pick the end date for this recurrence.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const dt = v.dt;
|
const dt = v.dt;
|
||||||
const spec = buildSpec(dt);
|
|
||||||
const rrule = buildRrule(spec, dt);
|
const rrule = buildRrule(spec, dt);
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
@ -154,14 +107,7 @@ export function EditWhenForm({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const previewDt =
|
const previewSentence = describeRecurrence(spec, previewDt);
|
||||||
date && time
|
|
||||||
? DateTime.fromFormat(`${date} ${time}`, "yyyy-MM-dd HH:mm", { zone: timezone })
|
|
||||||
: null;
|
|
||||||
const previewSentence =
|
|
||||||
previewDt && previewDt.isValid
|
|
||||||
? describeRecurrence(buildSpec(previewDt), previewDt)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
@ -169,7 +115,7 @@ export function EditWhenForm({
|
|||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="when-date" className="flex items-center gap-1.5">
|
<Label htmlFor="when-date" className="flex items-center gap-1.5">
|
||||||
<CalendarIcon className="size-3.5" />
|
<CalendarIcon className="size-3.5" />
|
||||||
{kind === "none" ? "Date" : "Starts on"}
|
{spec.kind === "none" ? "Date" : "Starts on"}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="when-date"
|
id="when-date"
|
||||||
@ -200,170 +146,7 @@ export function EditWhenForm({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<RecurrencePicker firstFire={previewDt} value={spec} onChange={setSpec} />
|
||||||
<Label className="flex items-center gap-1.5">
|
|
||||||
<RepeatIcon className="size-3.5" />
|
|
||||||
Repeats
|
|
||||||
</Label>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{KINDS.map(({ value, label }) => {
|
|
||||||
const active = kind === value;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={value}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setKind(value)}
|
|
||||||
aria-pressed={active}
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium transition-colors",
|
|
||||||
active
|
|
||||||
? "border-primary bg-primary text-primary-foreground"
|
|
||||||
: "border-border bg-muted/50 hover:border-primary/50 hover:bg-primary/5 hover:text-primary",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{kind !== "none" && (
|
|
||||||
<div className="space-y-4 rounded-xl border border-border bg-muted/30 p-4">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<Label htmlFor="when-interval" className="text-sm">
|
|
||||||
Every
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="when-interval"
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={999}
|
|
||||||
value={interval}
|
|
||||||
onChange={(e) => {
|
|
||||||
const n = Number(e.target.value);
|
|
||||||
setIntervalValue(Number.isFinite(n) && n >= 1 ? n : 1);
|
|
||||||
setError(null);
|
|
||||||
}}
|
|
||||||
className="h-8 w-20"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{FREQ_UNIT[kind]}
|
|
||||||
{interval === 1 ? "" : "s"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{kind === "weekly" && (
|
|
||||||
<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 active = weeklyDays.includes(iso);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={iso}
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggleWeekday(iso)}
|
|
||||||
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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{kind === "monthly" && (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="when-monthday" className="text-sm">
|
|
||||||
Day of the month
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="when-monthday"
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={31}
|
|
||||||
value={monthDay}
|
|
||||||
onChange={(e) => {
|
|
||||||
const v = e.target.value;
|
|
||||||
if (v === "") setMonthDay("");
|
|
||||||
else {
|
|
||||||
const n = Number(v);
|
|
||||||
if (Number.isFinite(n) && n >= 1 && n <= 31) setMonthDay(n);
|
|
||||||
}
|
|
||||||
setError(null);
|
|
||||||
}}
|
|
||||||
className="h-8 w-24"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-sm">Ends</Label>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{(["never", "after", "on"] as const).map((v) => {
|
|
||||||
const active = endKind === v;
|
|
||||||
const label = v === "never" ? "Never" : v === "after" ? "After…" : "On date…";
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={v}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setEndKind(v)}
|
|
||||||
aria-pressed={active}
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center rounded-full border px-3 py-1 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",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{endKind === "after" && (
|
|
||||||
<div className="flex items-center gap-2 pt-1">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={9999}
|
|
||||||
value={endCount}
|
|
||||||
onChange={(e) => {
|
|
||||||
const n = Number(e.target.value);
|
|
||||||
setEndCount(Number.isFinite(n) && n >= 1 ? n : 1);
|
|
||||||
setError(null);
|
|
||||||
}}
|
|
||||||
className="h-8 w-24"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
occurrence{endCount === 1 ? "" : "s"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{endKind === "on" && (
|
|
||||||
<div className="pt-1">
|
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
value={endUntil}
|
|
||||||
onChange={(e) => {
|
|
||||||
setEndUntil(e.target.value);
|
|
||||||
setError(null);
|
|
||||||
}}
|
|
||||||
className="h-8 w-44"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{previewSentence && (
|
{previewSentence && (
|
||||||
<p className="rounded-lg bg-primary/5 px-3 py-2 text-xs text-primary/80">
|
<p className="rounded-lg bg-primary/5 px-3 py-2 text-xs text-primary/80">
|
||||||
|
|||||||
@ -3,20 +3,18 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
import { CalendarIcon, ClockIcon, AlertCircleIcon, RepeatIcon } from "lucide-react";
|
import { CalendarIcon, ClockIcon, AlertCircleIcon } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import {
|
import {
|
||||||
WEEKDAY_LABELS,
|
|
||||||
buildRrule,
|
buildRrule,
|
||||||
describeRecurrence,
|
describeRecurrence,
|
||||||
type RecurrenceKind,
|
DEFAULT_RECURRENCE,
|
||||||
type RecurrenceSpec,
|
type RecurrenceSpec,
|
||||||
type EndKind,
|
|
||||||
} from "@/lib/recurrence";
|
} from "@/lib/recurrence";
|
||||||
import { splitDateTime, validateScheduledAt } from "@/lib/date-picker";
|
import { splitDateTime, validateScheduledAt } from "@/lib/date-picker";
|
||||||
|
import { RecurrencePicker } from "@/components/recurrence-picker";
|
||||||
|
|
||||||
interface PassThroughParams {
|
interface PassThroughParams {
|
||||||
text?: string;
|
text?: string;
|
||||||
@ -34,21 +32,6 @@ interface WhenFormClientProps {
|
|||||||
passThroughParams: PassThroughParams;
|
passThroughParams: PassThroughParams;
|
||||||
}
|
}
|
||||||
|
|
||||||
const KINDS: Array<{ value: RecurrenceKind; label: string }> = [
|
|
||||||
{ value: "none", label: "One-off" },
|
|
||||||
{ value: "daily", label: "Daily" },
|
|
||||||
{ value: "weekly", label: "Weekly" },
|
|
||||||
{ value: "monthly", label: "Monthly" },
|
|
||||||
{ value: "yearly", label: "Yearly" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const FREQ_UNIT: Record<Exclude<RecurrenceKind, "none">, string> = {
|
|
||||||
daily: "day",
|
|
||||||
weekly: "week",
|
|
||||||
monthly: "month",
|
|
||||||
yearly: "year",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function WhenFormClient({
|
export function WhenFormClient({
|
||||||
accountId,
|
accountId,
|
||||||
groupIds,
|
groupIds,
|
||||||
@ -62,75 +45,46 @@ export function WhenFormClient({
|
|||||||
|
|
||||||
const [date, setDate] = useState(initial.date);
|
const [date, setDate] = useState(initial.date);
|
||||||
const [time, setTime] = useState(initial.time);
|
const [time, setTime] = useState(initial.time);
|
||||||
const [kind, setKind] = useState<RecurrenceKind>(initialSpec?.kind ?? "none");
|
const [spec, setSpec] = useState<RecurrenceSpec>(initialSpec ?? DEFAULT_RECURRENCE);
|
||||||
const [interval, setIntervalValue] = useState<number>(initialSpec?.interval ?? 1);
|
|
||||||
const [weeklyDays, setWeeklyDays] = useState<number[]>(initialSpec?.weeklyDays ?? []);
|
|
||||||
const [monthDay, setMonthDay] = useState<number | "">(
|
|
||||||
initialSpec?.monthDay ?? "",
|
|
||||||
);
|
|
||||||
const [endKind, setEndKind] = useState<EndKind>(initialSpec?.end.kind ?? "never");
|
|
||||||
const [endCount, setEndCount] = useState<number>(
|
|
||||||
initialSpec?.end.kind === "after" ? initialSpec.end.count : 10,
|
|
||||||
);
|
|
||||||
const [endUntil, setEndUntil] = useState<string>(
|
|
||||||
initialSpec?.end.kind === "on" ? initialSpec.end.until : "",
|
|
||||||
);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
function toggleWeekday(iso: number) {
|
// The first-fire DateTime drives preset labels in the picker. Fall
|
||||||
setWeeklyDays((prev) =>
|
// back to the default ISO if the inputs aren't a valid pair yet.
|
||||||
prev.includes(iso) ? prev.filter((d) => d !== iso) : [...prev, iso].sort((a, b) => a - b),
|
const previewDt = (() => {
|
||||||
);
|
if (date && time) {
|
||||||
}
|
const d = DateTime.fromFormat(`${date} ${time}`, "yyyy-MM-dd HH:mm", { zone: timezone });
|
||||||
|
if (d.isValid) return d;
|
||||||
function buildSpec(firstFire: DateTime): RecurrenceSpec {
|
|
||||||
const safeMonthDay =
|
|
||||||
typeof monthDay === "number" && monthDay >= 1 && monthDay <= 31
|
|
||||||
? monthDay
|
|
||||||
: firstFire.day;
|
|
||||||
let end: RecurrenceSpec["end"] = { kind: "never" };
|
|
||||||
if (endKind === "after" && endCount > 0) {
|
|
||||||
end = { kind: "after", count: Math.floor(endCount) };
|
|
||||||
} else if (endKind === "on" && endUntil) {
|
|
||||||
end = { kind: "on", until: endUntil };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
kind,
|
|
||||||
interval: Math.max(1, Math.floor(interval || 1)),
|
|
||||||
weeklyDays,
|
|
||||||
monthDay: kind === "monthly" ? safeMonthDay : undefined,
|
|
||||||
end,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
return DateTime.fromISO(initialDefaultIso, { zone: timezone });
|
||||||
|
})();
|
||||||
|
|
||||||
function handleContinue() {
|
function handleContinue() {
|
||||||
const v = validateScheduledAt(date, time, timezone, Date.now());
|
const v = validateScheduledAt(date, time, timezone, Date.now());
|
||||||
if (!v.ok) {
|
if (!v.ok) {
|
||||||
const map: Record<typeof v.reason, string> = {
|
const map = {
|
||||||
missing: "Pick both a date and a time.",
|
missing: "Pick both a date and a time.",
|
||||||
invalid: "Invalid date or time.",
|
invalid: "Invalid date or time.",
|
||||||
past: "The first occurrence is in the past. Pick a future date and time.",
|
past: "The first occurrence is in the past. Pick a future date and time.",
|
||||||
};
|
} as const;
|
||||||
setError(map[v.reason]);
|
setError(map[v.reason]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const dt = v.dt;
|
const dt = v.dt;
|
||||||
if (endKind === "on" && !endUntil) {
|
if (spec.end.kind === "on" && !spec.end.until) {
|
||||||
setError("Pick the end date for this recurrence.");
|
setError("Pick the end date for this recurrence.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (endKind === "on" && endUntil) {
|
if (spec.end.kind === "on" && spec.end.until) {
|
||||||
const until = DateTime.fromISO(endUntil, { zone: timezone });
|
const until = DateTime.fromISO(spec.end.until, { zone: timezone });
|
||||||
if (until.isValid && until.toMillis() <= dt.toMillis()) {
|
if (until.isValid && until.toMillis() <= dt.toMillis()) {
|
||||||
setError("The end date must be after the first fire.");
|
setError("The end date must be after the first fire.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (endKind === "after" && (!Number.isFinite(endCount) || endCount < 1)) {
|
if (spec.end.kind === "after" && (!Number.isFinite(spec.end.count) || spec.end.count < 1)) {
|
||||||
setError("Number of occurrences must be at least 1.");
|
setError("Number of occurrences must be at least 1.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const spec = buildSpec(dt);
|
|
||||||
const rrule = buildRrule(spec, dt);
|
const rrule = buildRrule(spec, dt);
|
||||||
const scheduledAt = dt.toISO()!;
|
const scheduledAt = dt.toISO()!;
|
||||||
const sp = new URLSearchParams({
|
const sp = new URLSearchParams({
|
||||||
@ -148,15 +102,7 @@ export function WhenFormClient({
|
|||||||
router.push(`/reminders/new?${sp.toString()}` as any);
|
router.push(`/reminders/new?${sp.toString()}` as any);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Live preview text — uses the parsed first-fire if valid, else the date input alone.
|
const previewSentence = describeRecurrence(spec, previewDt);
|
||||||
const previewDt = (() => {
|
|
||||||
if (!date || !time) return null;
|
|
||||||
const d = DateTime.fromFormat(`${date} ${time}`, "yyyy-MM-dd HH:mm", { zone: timezone });
|
|
||||||
return d.isValid ? d : null;
|
|
||||||
})();
|
|
||||||
const previewSpec = previewDt ? buildSpec(previewDt) : null;
|
|
||||||
const previewSentence =
|
|
||||||
previewDt && previewSpec ? describeRecurrence(previewSpec, previewDt) : null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
@ -165,7 +111,7 @@ export function WhenFormClient({
|
|||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="scheduled-date" className="flex items-center gap-1.5">
|
<Label htmlFor="scheduled-date" className="flex items-center gap-1.5">
|
||||||
<CalendarIcon className="size-3.5" />
|
<CalendarIcon className="size-3.5" />
|
||||||
{kind === "none" ? "Date" : "Starts on"}
|
{spec.kind === "none" ? "Date" : "Starts on"}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="scheduled-date"
|
id="scheduled-date"
|
||||||
@ -196,186 +142,8 @@ export function WhenFormClient({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Frequency */}
|
<RecurrencePicker firstFire={previewDt} value={spec} onChange={setSpec} />
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="flex items-center gap-1.5">
|
|
||||||
<RepeatIcon className="size-3.5" />
|
|
||||||
Repeats
|
|
||||||
</Label>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{KINDS.map(({ value, label }) => {
|
|
||||||
const active = kind === value;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={value}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setKind(value)}
|
|
||||||
aria-pressed={active}
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium transition-colors",
|
|
||||||
active
|
|
||||||
? "border-primary bg-primary text-primary-foreground"
|
|
||||||
: "border-border bg-muted/50 hover:border-primary/50 hover:bg-primary/5 hover:text-primary",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Recurrence detail — interval, weekdays, monthday, end */}
|
|
||||||
{kind !== "none" && (
|
|
||||||
<div className="space-y-4 rounded-xl border border-border bg-muted/30 p-4">
|
|
||||||
{/* Interval */}
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<Label htmlFor="recur-interval" className="text-sm">
|
|
||||||
Every
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="recur-interval"
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={999}
|
|
||||||
value={interval}
|
|
||||||
onChange={(e) => {
|
|
||||||
const n = Number(e.target.value);
|
|
||||||
setIntervalValue(Number.isFinite(n) && n >= 1 ? n : 1);
|
|
||||||
setError(null);
|
|
||||||
}}
|
|
||||||
className="h-8 w-20"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{FREQ_UNIT[kind]}
|
|
||||||
{interval === 1 ? "" : "s"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Weekly days */}
|
|
||||||
{kind === "weekly" && (
|
|
||||||
<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 active = weeklyDays.includes(iso);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={iso}
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggleWeekday(iso)}
|
|
||||||
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>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Leave empty to use the start date's weekday only.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Monthly day-of-month */}
|
|
||||||
{kind === "monthly" && (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="recur-monthday" className="text-sm">
|
|
||||||
Day of the month
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="recur-monthday"
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={31}
|
|
||||||
value={monthDay}
|
|
||||||
onChange={(e) => {
|
|
||||||
const v = e.target.value;
|
|
||||||
if (v === "") {
|
|
||||||
setMonthDay("");
|
|
||||||
} else {
|
|
||||||
const n = Number(v);
|
|
||||||
if (Number.isFinite(n) && n >= 1 && n <= 31) setMonthDay(n);
|
|
||||||
}
|
|
||||||
setError(null);
|
|
||||||
}}
|
|
||||||
placeholder={String((previewDt ?? DateTime.now()).day)}
|
|
||||||
className="h-8 w-24"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Months without this day skip naturally (e.g. 31st).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* End condition */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-sm">Ends</Label>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{(["never", "after", "on"] as const).map((v) => {
|
|
||||||
const active = endKind === v;
|
|
||||||
const label = v === "never" ? "Never" : v === "after" ? "After…" : "On date…";
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={v}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setEndKind(v)}
|
|
||||||
aria-pressed={active}
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center rounded-full border px-3 py-1 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",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{endKind === "after" && (
|
|
||||||
<div className="flex items-center gap-2 pt-1">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={9999}
|
|
||||||
value={endCount}
|
|
||||||
onChange={(e) => {
|
|
||||||
const n = Number(e.target.value);
|
|
||||||
setEndCount(Number.isFinite(n) && n >= 1 ? n : 1);
|
|
||||||
setError(null);
|
|
||||||
}}
|
|
||||||
className="h-8 w-24"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
occurrence{endCount === 1 ? "" : "s"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{endKind === "on" && (
|
|
||||||
<div className="pt-1">
|
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
value={endUntil}
|
|
||||||
onChange={(e) => {
|
|
||||||
setEndUntil(e.target.value);
|
|
||||||
setError(null);
|
|
||||||
}}
|
|
||||||
className="h-8 w-44"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Live preview */}
|
|
||||||
{previewSentence && (
|
{previewSentence && (
|
||||||
<p className="rounded-lg bg-primary/5 px-3 py-2 text-xs text-primary/80">
|
<p className="rounded-lg bg-primary/5 px-3 py-2 text-xs text-primary/80">
|
||||||
{previewSentence}
|
{previewSentence}
|
||||||
|
|||||||
@ -4,6 +4,9 @@ import {
|
|||||||
buildRrule,
|
buildRrule,
|
||||||
describeRecurrence,
|
describeRecurrence,
|
||||||
kindFromRrule,
|
kindFromRrule,
|
||||||
|
matchPreset,
|
||||||
|
presetDescriptors,
|
||||||
|
presetToSpec,
|
||||||
specFromRrule,
|
specFromRrule,
|
||||||
type RecurrenceSpec,
|
type RecurrenceSpec,
|
||||||
} from "./recurrence";
|
} from "./recurrence";
|
||||||
@ -160,6 +163,91 @@ describe("specFromRrule / kindFromRrule", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("preset shortcuts (Repeats picker)", () => {
|
||||||
|
// FIRST is 2026-05-13 = Wednesday (ISO weekday 3), day 13, May.
|
||||||
|
it("presetToSpec produces the canonical RecurrenceSpec for each shortcut", () => {
|
||||||
|
expect(presetToSpec("none", FIRST)).toMatchObject({ kind: "none" });
|
||||||
|
expect(presetToSpec("daily", FIRST)).toMatchObject({ kind: "daily", interval: 1 });
|
||||||
|
expect(presetToSpec("weekdays", FIRST)).toMatchObject({
|
||||||
|
kind: "weekly",
|
||||||
|
weeklyDays: [1, 2, 3, 4, 5],
|
||||||
|
});
|
||||||
|
expect(presetToSpec("weekends", FIRST)).toMatchObject({
|
||||||
|
kind: "weekly",
|
||||||
|
weeklyDays: [6, 7],
|
||||||
|
});
|
||||||
|
expect(presetToSpec("weekly_same", FIRST)).toMatchObject({
|
||||||
|
kind: "weekly",
|
||||||
|
weeklyDays: [3], // Wed
|
||||||
|
});
|
||||||
|
expect(presetToSpec("monthly_same", FIRST)).toMatchObject({
|
||||||
|
kind: "monthly",
|
||||||
|
monthDay: 13,
|
||||||
|
});
|
||||||
|
expect(presetToSpec("yearly_same", FIRST)).toMatchObject({ kind: "yearly" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matchPreset round-trips through presetToSpec for every preset", () => {
|
||||||
|
for (const id of ["none", "daily", "weekdays", "weekends", "weekly_same", "monthly_same", "yearly_same"] as const) {
|
||||||
|
const spec = presetToSpec(id, FIRST);
|
||||||
|
expect(matchPreset(spec, FIRST)).toBe(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matchPreset returns 'custom' for anything that doesn't fit a shortcut", () => {
|
||||||
|
// Interval > 1 doesn't match any preset.
|
||||||
|
expect(
|
||||||
|
matchPreset(
|
||||||
|
{ kind: "daily", interval: 2, weeklyDays: [], end: { kind: "never" } },
|
||||||
|
FIRST,
|
||||||
|
),
|
||||||
|
).toBe("custom");
|
||||||
|
// Weekly Mon/Wed/Fri isn't a known shortcut.
|
||||||
|
expect(
|
||||||
|
matchPreset(
|
||||||
|
{ kind: "weekly", interval: 1, weeklyDays: [1, 3, 5], end: { kind: "never" } },
|
||||||
|
FIRST,
|
||||||
|
),
|
||||||
|
).toBe("custom");
|
||||||
|
// End=after takes us out of the preset matrix even at interval=1.
|
||||||
|
expect(
|
||||||
|
matchPreset(
|
||||||
|
{ kind: "daily", interval: 1, weeklyDays: [], end: { kind: "after", count: 5 } },
|
||||||
|
FIRST,
|
||||||
|
),
|
||||||
|
).toBe("custom");
|
||||||
|
// Monthly on a different day-of-month than the first fire.
|
||||||
|
expect(
|
||||||
|
matchPreset(
|
||||||
|
{ kind: "monthly", interval: 1, weeklyDays: [], monthDay: 1, end: { kind: "never" } },
|
||||||
|
FIRST,
|
||||||
|
),
|
||||||
|
).toBe("custom");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("presetDescriptors returns 8 entries with first-fire-aware labels", () => {
|
||||||
|
const items = presetDescriptors(FIRST);
|
||||||
|
expect(items.map((d) => d.id)).toEqual([
|
||||||
|
"none",
|
||||||
|
"daily",
|
||||||
|
"weekdays",
|
||||||
|
"weekends",
|
||||||
|
"weekly_same",
|
||||||
|
"monthly_same",
|
||||||
|
"yearly_same",
|
||||||
|
"custom",
|
||||||
|
]);
|
||||||
|
// Labels should be parameterised by firstFire.
|
||||||
|
expect(items.find((d) => d.id === "weekly_same")?.label).toBe("Every week on Wed");
|
||||||
|
expect(items.find((d) => d.id === "monthly_same")?.label).toBe(
|
||||||
|
"Every month on day 13",
|
||||||
|
);
|
||||||
|
expect(items.find((d) => d.id === "yearly_same")?.label).toBe(
|
||||||
|
"Every year on May 13",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("describeRecurrence", () => {
|
describe("describeRecurrence", () => {
|
||||||
it("renders a one-off label", () => {
|
it("renders a one-off label", () => {
|
||||||
expect(describeRecurrence(baseSpec({ kind: "none" }), FIRST)).toBe("One-off");
|
expect(describeRecurrence(baseSpec({ kind: "none" }), FIRST)).toBe("One-off");
|
||||||
|
|||||||
@ -194,3 +194,130 @@ export function specFromRrule(rrule: string | null | undefined): RecurrenceSpec
|
|||||||
export function kindFromRrule(rrule: string | null | undefined): RecurrenceKind {
|
export function kindFromRrule(rrule: string | null | undefined): RecurrenceKind {
|
||||||
return specFromRrule(rrule).kind;
|
return specFromRrule(rrule).kind;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Preset shortcuts for the Repeats picker
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export type PresetId =
|
||||||
|
| "none"
|
||||||
|
| "daily"
|
||||||
|
| "weekdays"
|
||||||
|
| "weekends"
|
||||||
|
| "weekly_same"
|
||||||
|
| "monthly_same"
|
||||||
|
| "yearly_same"
|
||||||
|
| "custom";
|
||||||
|
|
||||||
|
export interface PresetDescriptor {
|
||||||
|
id: PresetId;
|
||||||
|
/** Short label shown in the radio list. */
|
||||||
|
label: string;
|
||||||
|
/** Optional one-line hint shown beneath the label. */
|
||||||
|
hint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the canonical RecurrenceSpec for a preset given the first-fire
|
||||||
|
* DateTime (the spec depends on the chosen first fire — e.g. "every
|
||||||
|
* week on the same weekday" means whatever weekday firstFire lands on).
|
||||||
|
*/
|
||||||
|
export function presetToSpec(id: PresetId, firstFire: DateTime): RecurrenceSpec {
|
||||||
|
const base: RecurrenceSpec = {
|
||||||
|
kind: "none",
|
||||||
|
interval: 1,
|
||||||
|
weeklyDays: [],
|
||||||
|
end: { kind: "never" },
|
||||||
|
};
|
||||||
|
switch (id) {
|
||||||
|
case "none":
|
||||||
|
return base;
|
||||||
|
case "daily":
|
||||||
|
return { ...base, kind: "daily" };
|
||||||
|
case "weekdays":
|
||||||
|
return { ...base, kind: "weekly", weeklyDays: [1, 2, 3, 4, 5] };
|
||||||
|
case "weekends":
|
||||||
|
return { ...base, kind: "weekly", weeklyDays: [6, 7] };
|
||||||
|
case "weekly_same":
|
||||||
|
return { ...base, kind: "weekly", weeklyDays: [firstFire.weekday] };
|
||||||
|
case "monthly_same":
|
||||||
|
return { ...base, kind: "monthly", monthDay: firstFire.day };
|
||||||
|
case "yearly_same":
|
||||||
|
return { ...base, kind: "yearly" };
|
||||||
|
case "custom":
|
||||||
|
// Custom is only meaningful in the picker UI — when the user
|
||||||
|
// explicitly opts into it, callers should preserve whatever
|
||||||
|
// detailed spec the user already had. Return a sensible weekly
|
||||||
|
// default if the caller forgets to pass through.
|
||||||
|
return { ...base, kind: "weekly", weeklyDays: [firstFire.weekday] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse mapping: which preset (if any) does this spec match?
|
||||||
|
*
|
||||||
|
* Returns "custom" for anything that doesn't match a known shortcut —
|
||||||
|
* the picker uses that to flip into expanded-detail mode.
|
||||||
|
*/
|
||||||
|
export function matchPreset(spec: RecurrenceSpec, firstFire: DateTime): PresetId {
|
||||||
|
if (spec.kind === "none") return "none";
|
||||||
|
|
||||||
|
const sameInterval = spec.interval === 1;
|
||||||
|
const noEnd = spec.end.kind === "never";
|
||||||
|
if (!sameInterval || !noEnd) return "custom";
|
||||||
|
|
||||||
|
const sortedWeeklyDays = (days: number[]) => days.slice().sort((a, b) => a - b).join(",");
|
||||||
|
|
||||||
|
switch (spec.kind) {
|
||||||
|
case "daily":
|
||||||
|
return "daily";
|
||||||
|
case "weekly": {
|
||||||
|
const days = spec.weeklyDays.length === 0 ? [firstFire.weekday] : spec.weeklyDays;
|
||||||
|
const key = sortedWeeklyDays(days);
|
||||||
|
if (key === "1,2,3,4,5") return "weekdays";
|
||||||
|
if (key === "6,7") return "weekends";
|
||||||
|
if (key === String(firstFire.weekday)) return "weekly_same";
|
||||||
|
return "custom";
|
||||||
|
}
|
||||||
|
case "monthly":
|
||||||
|
if ((spec.monthDay ?? firstFire.day) === firstFire.day) return "monthly_same";
|
||||||
|
return "custom";
|
||||||
|
case "yearly":
|
||||||
|
return "yearly_same";
|
||||||
|
case "none":
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the (firstFire-aware) labels and hints for the radio list.
|
||||||
|
* The hint shows the concrete weekday/day-of-month/date the preset
|
||||||
|
* would imply given the user's chosen first fire.
|
||||||
|
*/
|
||||||
|
export function presetDescriptors(firstFire: DateTime): PresetDescriptor[] {
|
||||||
|
const dayShort = WEEKDAY_LABELS[firstFire.weekday - 1]?.short ?? "";
|
||||||
|
return [
|
||||||
|
{ id: "none", label: "Don't repeat", hint: "Fires once and ends" },
|
||||||
|
{ id: "daily", label: "Every day" },
|
||||||
|
{ id: "weekdays", label: "Every weekday", hint: "Mon – Fri" },
|
||||||
|
{ id: "weekends", label: "Every weekend", hint: "Sat – Sun" },
|
||||||
|
{
|
||||||
|
id: "weekly_same",
|
||||||
|
label: `Every week on ${dayShort}`,
|
||||||
|
hint: "Same weekday as the start date",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "monthly_same",
|
||||||
|
label: `Every month on day ${firstFire.day}`,
|
||||||
|
hint: "Months without this day skip naturally (e.g. 31st)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "yearly_same",
|
||||||
|
label: `Every year on ${firstFire.toFormat("MMM d")}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "custom",
|
||||||
|
label: "Custom…",
|
||||||
|
hint: "Set interval, days, and end conditions yourself",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user