feat: edit reminders, mature recurrence, QR throttle, more tests

Reminders
- Reminder list / detail show recurrence summary ("Every Mon, Wed, Fri",
  "Every 2 weeks until 2027-01-01", etc).
- Detail page reorganised: each section (Account / Message / When /
  Groups) is itself a clickable card that deep-links into the wizard
  step in edit mode (editReminderId URL param). No standalone Edit
  button. Run history stays read-only.
- New /reminders/[id]/edit shell loads the row, encodes its state into
  wizard URL params, and forwards to /reminders/new. The wizard
  threads editReminderId through every step.
- updateReminderAction: validates ownership of both the existing
  reminder and the (possibly changed) target account, replaces targets
  + messages wholesale, re-arms the pg-boss job (singleton key picks
  up the new fire time).
- Wizard submit branches to updateReminderAction when editReminderId
  is set; button reads "Save changes" / "Saving…".
- Wizard default first-fire is now the current minute in the operator
  zone (not now+1h). Same-minute clicks bump silently to next minute
  via a 60 s grace window so the user isn't punished.
- /reminders empty state is filter-aware: "No failed reminders yet."
  when ?filter=failed and there are reminders in other states.

Recurrence
- Spec is now a structured object: { kind, interval, weeklyDays,
  monthDay, end }. Builder produces RRULEs with INTERVAL, BYDAY,
  BYMONTHDAY, COUNT, UNTIL as appropriate. specFromRrule round-trips
  for resuming/edit.
- When-step UI: frequency pills, "Every N days/weeks/…" interval,
  weekday picker (weekly), day-of-month input (monthly), end picker
  (Never / After N occurrences / On date), live human-readable
  summary preview.

QR pairing
- Throttle QR refresh to once per 25 s and detach the previous
  per-account session listener on Re-pair so listeners can't
  accumulate. The UI countdown was flicking every ~5 s because each
  Re-pair attached an extra listener — every Baileys QR event then
  triggered a fresh DB write + NOTIFY.

Tests (60 green total, +33 in this batch)
- recurrence.test.ts: extended to 25 tests covering interval,
  monthday, end conditions (COUNT/UNTIL), and round-trip parsing.
- date-picker.test.ts: 14 tests for splitDateTime / combineDateTime /
  validateScheduledAt (incl. the "click-too-fast" same-minute grace)
  and defaultFirstFireIso.
- /api/qr/[accountId] route.test.ts: 4 tests — 404 when no QR yet,
  404 on missing row, 200 with image/png + no-store + correct PNG
  bytes, and verifies the where-clause queries by accountId.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-05-10 01:22:22 +08:00
parent 4f6d9c3f38
commit f19ea03e0d
20 changed files with 1267 additions and 270 deletions

View File

@ -14,8 +14,13 @@ import { pgNotifyWeb } from "./notify.js";
const PAIR_TIMEOUT_MS = 5 * 60 * 1000; const PAIR_TIMEOUT_MS = 5 * 60 * 1000;
const offByAccount = new Map<string, () => void>(); const offByAccount = new Map<string, () => void>();
const lastQrPayload = new Map<string, string>(); const lastQrPayload = new Map<string, string>();
const lastQrEmitMs = new Map<string, number>();
const pairTimeouts = new Map<string, NodeJS.Timeout>(); const pairTimeouts = new Map<string, NodeJS.Timeout>();
// Minimum spacing between QR refresh notifications. Prevents the UI from
// flashing through a new QR every few seconds when Baileys re-emits.
const QR_THROTTLE_MS = 25_000;
async function abandonPair(accountId: string): Promise<{ existed: boolean; label: string | null }> { async function abandonPair(accountId: string): Promise<{ existed: boolean; label: string | null }> {
const account = await db.query.whatsappAccounts.findFirst({ const account = await db.query.whatsappAccounts.findFirst({
where: (a, { eq }) => eq(a.id, accountId), where: (a, { eq }) => eq(a.id, accountId),
@ -51,6 +56,16 @@ export async function handleStartPairing(accountId: string): Promise<void> {
return; return;
} }
// Detach any listener still subscribed from a prior pairing attempt for
// this account. Without this, repeated Re-pair clicks accumulate
// listeners and each one writes a fresh QR to the DB on every Baileys
// event — the UI then flashes through new QRs constantly.
const prevOff = offByAccount.get(accountId);
if (prevOff) {
prevOff();
offByAccount.delete(accountId);
}
// For Re-pair, an old session may still be alive. Stop it so // For Re-pair, an old session may still be alive. Stop it so
// sessionManager.start() actually opens a fresh socket and Baileys emits // sessionManager.start() actually opens a fresh socket and Baileys emits
// a new QR. (start() is a no-op when a session is already registered.) // a new QR. (start() is a no-op when a session is already registered.)
@ -59,6 +74,7 @@ export async function handleStartPairing(accountId: string): Promise<void> {
} }
// Clear any stale QR lingering from a prior attempt. // Clear any stale QR lingering from a prior attempt.
lastQrPayload.delete(accountId); lastQrPayload.delete(accountId);
lastQrEmitMs.delete(accountId);
await db await db
.update(whatsappAccounts) .update(whatsappAccounts)
.set({ lastQrPng: null }) .set({ lastQrPng: null })
@ -69,7 +85,15 @@ export async function handleStartPairing(accountId: string): Promise<void> {
try { try {
if (event.type === "qr") { if (event.type === "qr") {
if (lastQrPayload.get(id) === event.payload) return; if (lastQrPayload.get(id) === event.payload) return;
const lastEmit = lastQrEmitMs.get(id) ?? 0;
const now = Date.now();
if (now - lastEmit < QR_THROTTLE_MS) {
// Baileys re-emits new QRs aggressively; surface no more than
// one every QR_THROTTLE_MS so the UI countdown doesn't flicker.
return;
}
lastQrPayload.set(id, event.payload); lastQrPayload.set(id, event.payload);
lastQrEmitMs.set(id, now);
const png = await renderQrPng(event.payload); const png = await renderQrPng(event.payload);
// PNG is too large (~5-10KB) for pg_notify (8000 byte limit). // PNG is too large (~5-10KB) for pg_notify (8000 byte limit).
// Persist on the account row; web fetches via /api/qr/[id]. // Persist on the account row; web fetches via /api/qr/[id].
@ -80,7 +104,7 @@ export async function handleStartPairing(accountId: string): Promise<void> {
await pgNotifyWeb({ await pgNotifyWeb({
type: "session.qr", type: "session.qr",
accountId: id, accountId: id,
ts: Date.now(), ts: now,
}); });
} else if (event.type === "open") { } else if (event.type === "open") {
const t = pairTimeouts.get(id); const t = pairTimeouts.get(id);
@ -89,6 +113,7 @@ export async function handleStartPairing(accountId: string): Promise<void> {
pairTimeouts.delete(id); pairTimeouts.delete(id);
} }
lastQrPayload.delete(id); lastQrPayload.delete(id);
lastQrEmitMs.delete(id);
offByAccount.delete(id); offByAccount.delete(id);
const session = sessionManager.getSession(id); const session = sessionManager.getSession(id);
let synced = 0; let synced = 0;
@ -122,6 +147,7 @@ export async function handleStartPairing(accountId: string): Promise<void> {
pairTimeouts.delete(id); pairTimeouts.delete(id);
} }
lastQrPayload.delete(id); lastQrPayload.delete(id);
lastQrEmitMs.delete(id);
offByAccount.delete(id); offByAccount.delete(id);
await pgNotifyWeb({ type: "session.timeout", accountId: id }); await pgNotifyWeb({ type: "session.timeout", accountId: id });
off(); off();

View File

@ -149,3 +149,119 @@ export async function createReminderAction(
return { ok: true, reminderId }; return { ok: true, reminderId };
} }
const updateReminderSchema = createReminderSchema.and(
z.object({ reminderId: z.string().uuid() }),
);
export type UpdateReminderResult =
| { ok: true; reminderId: string }
| { ok: false; error: string };
export async function updateReminderAction(
input: z.infer<typeof updateReminderSchema>,
): Promise<UpdateReminderResult> {
await rateLimit("update-reminder");
const parsed = updateReminderSchema.safeParse(input);
if (!parsed.success) {
return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input" };
}
const {
reminderId,
accountId,
groupIds,
text,
mediaId,
caption,
scheduledAtIso,
rrule,
timezone,
} = parsed.data;
const op = await getSeededOperator();
// Verify the reminder exists, the operator owns its account, and the
// (possibly changed) target account is also theirs.
const existing = await db.query.reminders.findFirst({
where: (r, { eq }) => eq(r.id, reminderId),
});
if (!existing) return { ok: false, error: "Reminder not found" };
const ownerOfExisting = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) => and(eq(a.id, existing.accountId), eq(a.operatorId, op.id)),
});
if (!ownerOfExisting) return { ok: false, error: "Reminder not yours" };
const targetAccount = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, op.id)),
});
if (!targetAccount) return { ok: false, error: "Account not yours" };
const scheduledAt = DateTime.fromISO(scheduledAtIso, { zone: timezone }).toJSDate();
if (Number.isNaN(scheduledAt.getTime())) {
return { ok: false, error: "Invalid date" };
}
if (scheduledAt.getTime() <= Date.now()) {
return { ok: false, error: "Time is in the past" };
}
const groups = await db.query.whatsappGroups.findMany({
where: (g, { eq, inArray, and }) => and(eq(g.accountId, accountId), inArray(g.id, groupIds)),
});
if (groups.length !== groupIds.length) {
return { ok: false, error: "One or more groups don't belong to this account" };
}
await db.transaction(async (tx) => {
await tx
.update(reminders)
.set({
accountId,
name: (text ?? caption ?? "Reminder").slice(0, 50),
scheduleKind: rrule ? "recurring" : "one_off",
scheduledAt,
rrule: rrule ?? null,
timezone,
status: "active",
updatedAt: new Date(),
})
.where(eq(reminders.id, reminderId));
// Replace targets and messages wholesale — simpler than diffing.
await tx.delete(reminderTargets).where(eq(reminderTargets.reminderId, reminderId));
if (groupIds.length > 0) {
await tx.insert(reminderTargets).values(
groupIds.map((groupId, position) => ({ reminderId, groupId, position })),
);
}
await tx.delete(reminderMessages).where(eq(reminderMessages.reminderId, reminderId));
if (text && !mediaId) {
await tx.insert(reminderMessages).values({
reminderId,
position: 0,
kind: "text",
textContent: text,
mediaId: null,
});
} else if (mediaId) {
await tx.insert(reminderMessages).values({
reminderId,
position: 0,
kind: "media",
textContent: caption ?? text ?? null,
mediaId,
});
}
});
// Re-arm the pg-boss job at the new scheduled time. The handler uses
// singletonKey=reminder:<id> so this supersedes the prior arming.
await pgNotifyBot({
type: "reminder.schedule",
reminderId,
scheduledAtIso: scheduledAt.toISOString(),
});
revalidatePath("/reminders");
revalidatePath(`/reminders/${reminderId}`);
return { ok: true, reminderId };
}

View File

@ -0,0 +1,82 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// Mock the db module before importing the route — the route reaches into
// `db.query.whatsappAccounts.findFirst`. Each test sets the resolved value.
const findFirstMock = vi.fn();
vi.mock("@/lib/db", () => ({
db: {
query: {
whatsappAccounts: {
findFirst: (...args: unknown[]) => findFirstMock(...args),
},
},
},
}));
import { GET } from "./route";
const ACCOUNT_ID = "11111111-1111-1111-1111-111111111111";
const ctx = { params: Promise.resolve({ accountId: ACCOUNT_ID }) };
// "PNG\r\n\x1A\n" — start of a valid PNG, in base64.
const FAKE_PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
describe("GET /api/qr/[accountId]", () => {
beforeEach(() => {
findFirstMock.mockReset();
});
it("returns 404 when the account has no QR yet", async () => {
findFirstMock.mockResolvedValue({ lastQrPng: null });
const res = await GET(new Request("http://x/api/qr/x"), ctx);
expect(res.status).toBe(404);
});
it("returns 404 when the account row doesn't exist", async () => {
findFirstMock.mockResolvedValue(undefined);
const res = await GET(new Request("http://x/api/qr/x"), ctx);
expect(res.status).toBe(404);
});
it("returns 200 with the PNG bytes and the right headers when a QR is present", async () => {
findFirstMock.mockResolvedValue({ lastQrPng: FAKE_PNG_BASE64 });
const res = await GET(new Request("http://x/api/qr/x"), ctx);
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toBe("image/png");
// The endpoint serves a fresh QR each time the SSE bumps the timestamp,
// so it must not be cached.
expect(res.headers.get("cache-control")).toBe("no-store");
// Body should round-trip exactly back to the stored base64.
const buf = Buffer.from(await res.arrayBuffer());
expect(buf.toString("base64")).toBe(FAKE_PNG_BASE64);
// Sanity check: starts with the PNG magic bytes \x89 P N G.
expect(buf[0]).toBe(0x89);
expect(buf.subarray(1, 4).toString()).toBe("PNG");
});
it("queries the DB by the URL accountId", async () => {
findFirstMock.mockResolvedValue({ lastQrPng: FAKE_PNG_BASE64 });
await GET(new Request("http://x/api/qr/x"), ctx);
expect(findFirstMock).toHaveBeenCalledTimes(1);
const arg = findFirstMock.mock.calls[0]![0] as { where: unknown; columns: unknown };
expect(arg.columns).toEqual({ lastQrPng: true });
// Exercise the `where` predicate Drizzle would call with the schema +
// operator helpers. The route passes a closure that only uses `eq`.
let captured: unknown = null;
const fakeAccount = { id: "fake_id_col" };
const helpers = {
eq: (a: unknown, b: unknown) => {
captured = [a, b];
return "EQ_PREDICATE";
},
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = (arg.where as any)(fakeAccount, helpers);
expect(result).toBe("EQ_PREDICATE");
expect(captured).toEqual([fakeAccount.id, ACCOUNT_ID]);
});
});

View File

@ -0,0 +1,50 @@
import { notFound, redirect } from "next/navigation";
import { getSeededOperator } from "@/lib/operator";
import { getReminderWithRuns } from "@/lib/queries";
interface Props {
params: Promise<{ id: string }>;
}
/**
* Edit shell load the reminder, encode its current state into the wizard's
* URL params (step 2 = Compose), and forward the user there. The wizard's
* review-submit branch detects `editReminderId` and calls
* updateReminderAction instead of createReminderAction.
*/
export default async function EditReminderRedirectPage({ params }: Props) {
const { id } = await params;
const op = await getSeededOperator();
const data = await getReminderWithRuns(op.id, id);
if (!data) notFound();
const { reminder, targets, messages } = data;
const sp = new URLSearchParams({
step: "2",
accountId: reminder.accountId,
editReminderId: reminder.id,
});
const groupIds = targets.map((t) => t.groupId).filter(Boolean).join(",");
if (groupIds) sp.set("groupIds", groupIds);
// Use the first message part for text/media — multi-part editing is out of scope.
const first = messages[0];
if (first?.textContent) {
if (first.mediaId) {
sp.set("caption", first.textContent);
sp.set("mediaId", first.mediaId);
} else {
sp.set("text", first.textContent);
}
} else if (first?.mediaId) {
sp.set("mediaId", first.mediaId);
}
if (reminder.scheduledAt) sp.set("scheduledAt", reminder.scheduledAt.toISOString());
if (reminder.rrule) sp.set("rrule", reminder.rrule);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
redirect(`/reminders/new?${sp.toString()}` as any);
}

View File

@ -7,15 +7,16 @@ import {
UsersIcon, UsersIcon,
ClockIcon, ClockIcon,
FileTextIcon, FileTextIcon,
RepeatIcon,
PencilIcon,
} from "lucide-react"; } from "lucide-react";
import { DateTime } from "luxon";
import { describeRecurrence, specFromRrule } from "@/lib/recurrence";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { import {
Card, Card,
CardContent, CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { import {
@ -30,9 +31,6 @@ import { getSeededOperator } from "@/lib/operator";
import { getReminderWithRuns } from "@/lib/queries"; import { getReminderWithRuns } from "@/lib/queries";
import { DeleteDialog } from "./delete-dialog"; import { DeleteDialog } from "./delete-dialog";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function formatWhen(date: Date | null, tz: string): string { function formatWhen(date: Date | null, tz: string): string {
if (!date) return "—"; if (!date) return "—";
return new Intl.DateTimeFormat("en-MY", { return new Intl.DateTimeFormat("en-MY", {
@ -45,9 +43,6 @@ function formatWhen(date: Date | null, tz: string): string {
}).format(new Date(date)); }).format(new Date(date));
} }
// ---------------------------------------------------------------------------
// Status pill
// ---------------------------------------------------------------------------
const STATUS_STYLES: Record<string, string> = { const STATUS_STYLES: Record<string, string> = {
active: active:
"bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent", "bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent",
@ -73,10 +68,6 @@ function StatusPill({ status }: { status: string }) {
); );
} }
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
interface Props { interface Props {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
} }
@ -94,9 +85,40 @@ export default async function ReminderDetailPage({ params }: Props) {
const { reminder, account, targets, messages, runs } = data; const { reminder, account, targets, messages, runs } = data;
const tz = op.defaultTimezone ?? "UTC"; const tz = op.defaultTimezone ?? "UTC";
// Build a wizard URL pointing at `step` with the current reminder state
// serialised — the wizard's review-submit detects editReminderId and
// routes to updateReminderAction instead of createReminderAction.
function editStepHref(step: number): string {
const sp = new URLSearchParams({
step: String(step),
accountId: reminder.accountId,
editReminderId: reminder.id,
});
const groupIds = targets.map((t) => t.groupId).filter(Boolean).join(",");
if (groupIds) sp.set("groupIds", groupIds);
const first = messages[0];
if (first?.textContent) {
if (first.mediaId) {
sp.set("caption", first.textContent);
sp.set("mediaId", first.mediaId);
} else {
sp.set("text", first.textContent);
}
} else if (first?.mediaId) {
sp.set("mediaId", first.mediaId);
}
if (reminder.scheduledAt) sp.set("scheduledAt", reminder.scheduledAt.toISOString());
if (reminder.rrule) sp.set("rrule", reminder.rrule);
return `/reminders/new?${sp.toString()}`;
}
const cardClasses =
"transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer";
const linkWrapperClasses =
"block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2";
return ( return (
<div className="px-4 py-6 sm:px-6 sm:py-8 max-w-3xl mx-auto space-y-6"> <div className="px-4 py-6 sm:px-6 sm:py-8 max-w-3xl mx-auto space-y-6">
{/* Back link */}
<Button asChild variant="ghost" size="sm" className="-ml-2"> <Button asChild variant="ghost" size="sm" className="-ml-2">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={"/reminders" as any}> <Link href={"/reminders" as any}>
@ -105,7 +127,6 @@ export default async function ReminderDetailPage({ params }: Props) {
</Link> </Link>
</Button> </Button>
{/* Header */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-start gap-3 flex-wrap"> <div className="flex items-start gap-3 flex-wrap">
<h1 className="text-2xl font-semibold tracking-tight leading-tight flex-1 min-w-0"> <h1 className="text-2xl font-semibold tracking-tight leading-tight flex-1 min-w-0">
@ -113,76 +134,136 @@ export default async function ReminderDetailPage({ params }: Props) {
</h1> </h1>
<StatusPill status={reminder.status} /> <StatusPill status={reminder.status} />
</div> </div>
<div className="flex flex-col gap-1"> <p className="text-xs text-muted-foreground italic">
<div className="flex items-center gap-1.5 text-sm text-muted-foreground"> Tap any section below to edit it.
<CalendarIcon className="size-3.5 shrink-0" /> </p>
<span>When: {formatWhen(reminder.scheduledAt, tz)}</span>
</div>
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<SmartphoneIcon className="size-3.5 shrink-0" />
<span>Account: {account.label}</span>
</div>
</div>
</div> </div>
<Separator /> <Separator />
{/* Message body */} {/* Account — click to edit step 1 */}
<section className="space-y-3"> {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<h2 className="text-base font-medium tracking-tight flex items-center gap-2"> <Link href={editStepHref(1) as any} className={linkWrapperClasses} aria-label="Edit account">
<Card className={cardClasses}>
<CardContent className="flex items-start gap-3 py-4 px-4">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">
<SmartphoneIcon className="size-4 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Account
</p>
<p className="text-sm font-medium truncate">{account.label}</p>
{account.phoneNumber && (
<p className="text-xs text-muted-foreground truncate">{account.phoneNumber}</p>
)}
</div>
<PencilIcon className="size-3.5 text-muted-foreground/60 mt-1 shrink-0" />
</CardContent>
</Card>
</Link>
{/* Message — click to edit step 2 */}
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={editStepHref(2) as any} className={linkWrapperClasses} aria-label="Edit message">
<Card className={cardClasses}>
<CardContent className="flex items-start gap-3 py-4 px-4">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">
<FileTextIcon className="size-4 text-muted-foreground" /> <FileTextIcon className="size-4 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1 space-y-1">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Message Message
</h2> </p>
<Card>
<CardContent className="py-4 space-y-3">
{messages.length === 0 ? ( {messages.length === 0 ? (
<p className="text-sm text-muted-foreground italic">No message parts defined.</p> <p className="text-sm text-muted-foreground italic">No message parts defined.</p>
) : ( ) : (
messages.map((msg, i) => ( messages.map((msg, i) => (
<div key={msg.id}> <div key={msg.id}>
{i > 0 && <Separator className="my-3" />} {i > 0 && <Separator className="my-2" />}
{msg.kind === "text" && msg.textContent ? ( {msg.kind === "text" && msg.textContent ? (
<p className="text-sm whitespace-pre-wrap">{msg.textContent}</p> <p className="text-sm whitespace-pre-wrap break-words">
{msg.textContent}
</p>
) : ( ) : (
<div className="space-y-1">
<p className="text-sm font-mono text-muted-foreground"> <p className="text-sm font-mono text-muted-foreground">
[{msg.kind}]{msg.textContent ? ` ${msg.textContent}` : ""} [{msg.kind}]{msg.textContent ? ` ${msg.textContent}` : ""}
</p> </p>
<p className="text-xs text-muted-foreground/70 italic">
Media preview coming soon.
</p>
</div>
)} )}
</div> </div>
)) ))
)} )}
</div>
<PencilIcon className="size-3.5 text-muted-foreground/60 mt-1 shrink-0" />
</CardContent> </CardContent>
</Card> </Card>
</section> </Link>
{/* Target groups */} {/* When / Recurrence — click to edit step 3 */}
{targets.length > 0 && ( {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<section className="space-y-3"> <Link href={editStepHref(3) as any} className={linkWrapperClasses} aria-label="Edit schedule">
<h2 className="text-base font-medium tracking-tight flex items-center gap-2"> <Card className={cardClasses}>
<CardContent className="flex items-start gap-3 py-4 px-4">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">
<CalendarIcon className="size-4 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{reminder.rrule ? "First fire" : "When"}
</p>
<p className="text-sm font-medium">{formatWhen(reminder.scheduledAt, tz)}</p>
{reminder.rrule && reminder.scheduledAt ? (
<p className="flex items-center gap-1.5 text-xs text-primary/80">
<RepeatIcon className="size-3 shrink-0" />
{describeRecurrence(
specFromRrule(reminder.rrule),
DateTime.fromJSDate(reminder.scheduledAt).setZone(reminder.timezone),
)}
</p>
) : (
<p className="text-xs text-muted-foreground">One-off</p>
)}
</div>
<PencilIcon className="size-3.5 text-muted-foreground/60 mt-1 shrink-0" />
</CardContent>
</Card>
</Link>
{/* Groups — click to edit step 4 */}
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={editStepHref(4) as any} className={linkWrapperClasses} aria-label="Edit groups">
<Card className={cardClasses}>
<CardContent className="flex items-start gap-3 py-4 px-4">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">
<UsersIcon className="size-4 text-muted-foreground" /> <UsersIcon className="size-4 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1 space-y-1">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Groups Groups
<Badge variant="outline" className="ml-1"> {targets.length > 0 ? ` · ${targets.length}` : " · none"}
{targets.length} </p>
</Badge> {targets.length === 0 ? (
</h2> <p className="text-sm text-muted-foreground italic">
<div className="flex flex-wrap gap-2"> No groups reminder won't deliver until you add at least one
</p>
) : (
<div className="flex flex-wrap gap-1.5">
{targets.map((t) => ( {targets.map((t) => (
<Badge key={t.groupId} variant="secondary"> <Badge key={t.groupId} variant="secondary">
{t.groupName} {t.groupName}
</Badge> </Badge>
))} ))}
</div> </div>
</section>
)} )}
</div>
<PencilIcon className="size-3.5 text-muted-foreground/60 mt-1 shrink-0" />
</CardContent>
</Card>
</Link>
<Separator /> <Separator />
{/* Run history */} {/* Run history — read-only */}
<section className="space-y-3"> <section className="space-y-3">
<h2 className="text-base font-medium tracking-tight flex items-center gap-2"> <h2 className="text-base font-medium tracking-tight flex items-center gap-2">
<ClockIcon className="size-4 text-muted-foreground" /> <ClockIcon className="size-4 text-muted-foreground" />
@ -233,8 +314,8 @@ export default async function ReminderDetailPage({ params }: Props) {
)} )}
</section> </section>
{/* Action footer */} {/* Action footer — Delete only; section cards above handle editing */}
<div className="flex items-center justify-end pt-2 border-t"> <div className="flex items-center justify-end gap-2 pt-2 border-t">
<DeleteDialog reminderId={reminder.id} /> <DeleteDialog reminderId={reminder.id} />
</div> </div>
</div> </div>

View File

@ -15,7 +15,9 @@ interface PageProps {
mediaId?: string; mediaId?: string;
caption?: string; caption?: string;
scheduledAt?: string; scheduledAt?: string;
rrule?: string;
groupId?: string; groupId?: string;
editReminderId?: string;
}>; }>;
} }
@ -23,10 +25,13 @@ export default async function NewReminderPage({ searchParams }: PageProps) {
const sp = await searchParams; const sp = await searchParams;
const step = Number(sp.step ?? "1"); const step = Number(sp.step ?? "1");
if (![1, 2, 3, 4, 5].includes(step)) notFound(); if (![1, 2, 3, 4, 5].includes(step)) notFound();
const isEdit = Boolean(sp.editReminderId);
return ( return (
<div className="container mx-auto max-w-2xl space-y-6 p-4 sm:p-6"> <div className="container mx-auto max-w-2xl space-y-6 p-4 sm:p-6">
<h1 className="text-2xl font-semibold tracking-tight">New Reminder</h1> <h1 className="text-2xl font-semibold tracking-tight">
{isEdit ? "Edit Reminder" : "New Reminder"}
</h1>
<Stepper current={step} /> <Stepper current={step} />
{step === 1 && <StepAccount />} {step === 1 && <StepAccount />}
{step === 2 && <StepCompose params={sp} />} {step === 2 && <StepCompose params={sp} />}

View File

@ -1,11 +1,13 @@
import Link from "next/link"; import Link from "next/link";
import { PlusIcon, BellIcon, CalendarIcon, UsersIcon } from "lucide-react"; import { PlusIcon, BellIcon, CalendarIcon, UsersIcon, RepeatIcon } from "lucide-react";
import { DateTime } from "luxon";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { getSeededOperator } from "@/lib/operator"; import { getSeededOperator } from "@/lib/operator";
import { listReminders } from "@/lib/queries"; import { listReminders } from "@/lib/queries";
import { describeRecurrence, specFromRrule } from "@/lib/recurrence";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
@ -138,12 +140,23 @@ export default async function RemindersPage({ searchParams }: PageProps) {
</p> </p>
</div> </div>
{/* When + group count */} {/* When + recurrence + group count */}
<div className="shrink-0 text-right space-y-1"> <div className="shrink-0 text-right space-y-1">
<div className="flex items-center justify-end gap-1 text-xs text-muted-foreground"> <div className="flex items-center justify-end gap-1 text-xs text-muted-foreground">
<CalendarIcon className="size-3 shrink-0" /> <CalendarIcon className="size-3 shrink-0" />
<span>{formatWhen(reminder.scheduledAt, tz)}</span> <span>{formatWhen(reminder.scheduledAt, tz)}</span>
</div> </div>
{reminder.rrule && reminder.scheduledAt ? (
<div className="flex items-center justify-end gap-1 text-xs text-primary/80">
<RepeatIcon className="size-3 shrink-0" />
<span>
{describeRecurrence(
specFromRrule(reminder.rrule),
DateTime.fromJSDate(reminder.scheduledAt).setZone(reminder.timezone),
)}
</span>
</div>
) : null}
{reminder.groupCount > 0 && ( {reminder.groupCount > 0 && (
<div className="flex items-center justify-end gap-1 text-xs text-muted-foreground"> <div className="flex items-center justify-end gap-1 text-xs text-muted-foreground">
<UsersIcon className="size-3 shrink-0" /> <UsersIcon className="size-3 shrink-0" />
@ -164,11 +177,18 @@ export default async function RemindersPage({ searchParams }: PageProps) {
<CardContent className="flex flex-col items-center gap-4 py-12 text-center"> <CardContent className="flex flex-col items-center gap-4 py-12 text-center">
<BellIcon className="size-10 text-muted-foreground/40" /> <BellIcon className="size-10 text-muted-foreground/40" />
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm font-medium">No reminders yet.</p> <p className="text-sm font-medium">
{filter === "all"
? "No reminders yet."
: `No ${filter} reminders yet.`}
</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Create a reminder to start sending scheduled WhatsApp messages. {allReminders.length === 0
? "Create a reminder to start sending scheduled WhatsApp messages."
: `Reminders in other states aren't shown by this filter.`}
</p> </p>
</div> </div>
{allReminders.length === 0 && (
<Button asChild size="sm"> <Button asChild size="sm">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={"/reminders/new" as any}> <Link href={"/reminders/new" as any}>
@ -176,6 +196,7 @@ export default async function RemindersPage({ searchParams }: PageProps) {
New Reminder New Reminder
</Link> </Link>
</Button> </Button>
)}
</CardContent> </CardContent>
</Card> </Card>
)} )}

View File

@ -19,6 +19,8 @@ import { cn } from "@/lib/utils";
interface PassThroughParams { interface PassThroughParams {
scheduledAt?: string; scheduledAt?: string;
rrule?: string;
editReminderId?: string;
} }
interface ComposeFormClientProps { interface ComposeFormClientProps {
@ -118,6 +120,8 @@ export function ComposeFormClient({
if (mediaId) sp.set("mediaId", mediaId); if (mediaId) sp.set("mediaId", mediaId);
if (caption.trim()) sp.set("caption", caption.trim()); if (caption.trim()) sp.set("caption", caption.trim());
if (passThroughParams.scheduledAt) sp.set("scheduledAt", passThroughParams.scheduledAt); if (passThroughParams.scheduledAt) sp.set("scheduledAt", passThroughParams.scheduledAt);
if (passThroughParams.rrule) sp.set("rrule", passThroughParams.rrule);
if (passThroughParams.editReminderId) sp.set("editReminderId", passThroughParams.editReminderId);
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
router.push(`/reminders/new?${sp.toString()}` as any); router.push(`/reminders/new?${sp.toString()}` as any);
} }

View File

@ -20,6 +20,7 @@ interface PassThroughParams {
caption?: string; caption?: string;
scheduledAt?: string; scheduledAt?: string;
rrule?: string; rrule?: string;
editReminderId?: string;
} }
interface GroupsFormClientProps { interface GroupsFormClientProps {
@ -72,6 +73,7 @@ export function GroupsFormClient({
if (passThroughParams.caption) sp.set("caption", passThroughParams.caption); if (passThroughParams.caption) sp.set("caption", passThroughParams.caption);
if (passThroughParams.scheduledAt) sp.set("scheduledAt", passThroughParams.scheduledAt); if (passThroughParams.scheduledAt) sp.set("scheduledAt", passThroughParams.scheduledAt);
if (passThroughParams.rrule) sp.set("rrule", passThroughParams.rrule); if (passThroughParams.rrule) sp.set("rrule", passThroughParams.rrule);
if (passThroughParams.editReminderId) sp.set("editReminderId", passThroughParams.editReminderId);
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
router.push(`/reminders/new?${sp.toString()}` as any); router.push(`/reminders/new?${sp.toString()}` as any);
} }

View File

@ -4,7 +4,7 @@ import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { CalendarCheckIcon, AlertCircleIcon, Loader2Icon } from "lucide-react"; import { CalendarCheckIcon, AlertCircleIcon, Loader2Icon } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { createReminderAction } from "@/actions/reminders"; import { createReminderAction, updateReminderAction } from "@/actions/reminders";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface ReviewSubmitClientProps { interface ReviewSubmitClientProps {
@ -15,6 +15,7 @@ interface ReviewSubmitClientProps {
caption?: string; caption?: string;
scheduledAt: string; scheduledAt: string;
rrule?: string; rrule?: string;
editReminderId?: string;
timezone: string; timezone: string;
} }
@ -26,6 +27,7 @@ export function ReviewSubmitClient({
caption, caption,
scheduledAt, scheduledAt,
rrule, rrule,
editReminderId,
timezone, timezone,
}: ReviewSubmitClientProps) { }: ReviewSubmitClientProps) {
const router = useRouter(); const router = useRouter();
@ -37,7 +39,7 @@ export function ReviewSubmitClient({
setError(null); setError(null);
try { try {
const result = await createReminderAction({ const payload = {
accountId, accountId,
groupIds: groupIds ? groupIds.split(",").filter(Boolean) : [], groupIds: groupIds ? groupIds.split(",").filter(Boolean) : [],
text: text ?? null, text: text ?? null,
@ -46,7 +48,10 @@ export function ReviewSubmitClient({
scheduledAtIso: scheduledAt, scheduledAtIso: scheduledAt,
rrule: rrule ?? null, rrule: rrule ?? null,
timezone, timezone,
}); };
const result = editReminderId
? await updateReminderAction({ ...payload, reminderId: editReminderId })
: await createReminderAction(payload);
if (result.ok) { if (result.ok) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -81,12 +86,12 @@ export function ReviewSubmitClient({
{submitting ? ( {submitting ? (
<> <>
<Loader2Icon className="size-4 animate-spin" /> <Loader2Icon className="size-4 animate-spin" />
Scheduling {editReminderId ? "Saving…" : "Scheduling…"}
</> </>
) : ( ) : (
<> <>
<CalendarCheckIcon className="size-4" /> <CalendarCheckIcon className="size-4" />
Schedule Reminder {editReminderId ? "Save changes" : "Schedule Reminder"}
</> </>
)} )}
</Button> </Button>

View File

@ -12,6 +12,8 @@ interface StepComposeParams {
mediaId?: string; mediaId?: string;
caption?: string; caption?: string;
scheduledAt?: string; scheduledAt?: string;
rrule?: string;
editReminderId?: string;
} }
interface StepComposeProps { interface StepComposeProps {
@ -52,6 +54,8 @@ export function StepCompose({ params }: StepComposeProps) {
initialCaption={caption} initialCaption={caption}
passThroughParams={{ passThroughParams={{
scheduledAt: params.scheduledAt, scheduledAt: params.scheduledAt,
rrule: params.rrule,
editReminderId: params.editReminderId,
}} }}
/> />
</div> </div>

View File

@ -16,6 +16,7 @@ interface StepGroupsParams {
scheduledAt?: string; scheduledAt?: string;
rrule?: string; rrule?: string;
groupId?: string; groupId?: string;
editReminderId?: string;
} }
interface StepGroupsProps { interface StepGroupsProps {
@ -31,6 +32,7 @@ export async function StepGroups({ params }: StepGroupsProps) {
text, text,
mediaId, mediaId,
rrule, rrule,
editReminderId,
} = params; } = params;
if (!accountId || !scheduledAt || (!text && !mediaId)) { if (!accountId || !scheduledAt || (!text && !mediaId)) {
@ -74,6 +76,7 @@ export async function StepGroups({ params }: StepGroupsProps) {
if (params.caption) backParams.set("caption", params.caption); if (params.caption) backParams.set("caption", params.caption);
if (scheduledAt) backParams.set("scheduledAt", scheduledAt); if (scheduledAt) backParams.set("scheduledAt", scheduledAt);
if (rrule) backParams.set("rrule", rrule); if (rrule) backParams.set("rrule", rrule);
if (editReminderId) backParams.set("editReminderId", editReminderId);
const backHref = `/reminders/new?${backParams.toString()}`; const backHref = `/reminders/new?${backParams.toString()}`;
return ( return (
@ -103,6 +106,7 @@ export async function StepGroups({ params }: StepGroupsProps) {
caption: params.caption, caption: params.caption,
scheduledAt: params.scheduledAt, scheduledAt: params.scheduledAt,
rrule, rrule,
editReminderId,
}} }}
/> />
</div> </div>
@ -118,6 +122,7 @@ interface PassThroughParams {
caption?: string; caption?: string;
scheduledAt?: string; scheduledAt?: string;
rrule?: string; rrule?: string;
editReminderId?: string;
} }
function StepGroupsForm({ function StepGroupsForm({

View File

@ -15,7 +15,7 @@ import { getSeededOperator } from "@/lib/operator";
import { getAccount, listGroupsForAccount } from "@/lib/queries"; import { getAccount, listGroupsForAccount } from "@/lib/queries";
import { ReviewSubmitClient } from "./review-submit-client"; import { ReviewSubmitClient } from "./review-submit-client";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import { describeRecurrence, kindFromRrule } from "@/lib/recurrence"; import { describeRecurrence, specFromRrule } from "@/lib/recurrence";
interface StepReviewParams { interface StepReviewParams {
step?: string; step?: string;
@ -26,25 +26,13 @@ interface StepReviewParams {
caption?: string; caption?: string;
scheduledAt?: string; scheduledAt?: string;
rrule?: string; rrule?: string;
editReminderId?: string;
} }
interface StepReviewProps { interface StepReviewProps {
params: StepReviewParams; params: StepReviewParams;
} }
const WEEKDAY_TO_ISO: Record<string, number> = {
MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6, SU: 7,
};
function parseWeeklyDaysFromRrule(rrule: string | undefined): number[] {
if (!rrule) return [];
const m = rrule.match(/BYDAY=([A-Z,]+)/i);
if (!m) return [];
return m[1]!
.split(",")
.map((d) => WEEKDAY_TO_ISO[d.toUpperCase()])
.filter((d): d is number => d !== undefined);
}
function formatScheduledAt(iso: string, timezone: string): string { function formatScheduledAt(iso: string, timezone: string): string {
try { try {
const dt = DateTime.fromISO(iso, { zone: timezone }); const dt = DateTime.fromISO(iso, { zone: timezone });
@ -64,6 +52,7 @@ function editLink(
caption?: string, caption?: string,
scheduledAt?: string, scheduledAt?: string,
rrule?: string, rrule?: string,
editReminderId?: string,
): string { ): string {
const sp = new URLSearchParams({ step: String(step), accountId }); const sp = new URLSearchParams({ step: String(step), accountId });
if (groupIds) sp.set("groupIds", groupIds); if (groupIds) sp.set("groupIds", groupIds);
@ -72,11 +61,12 @@ function editLink(
if (caption) sp.set("caption", caption); if (caption) sp.set("caption", caption);
if (scheduledAt) sp.set("scheduledAt", scheduledAt); if (scheduledAt) sp.set("scheduledAt", scheduledAt);
if (rrule) sp.set("rrule", rrule); if (rrule) sp.set("rrule", rrule);
if (editReminderId) sp.set("editReminderId", editReminderId);
return `/reminders/new?${sp.toString()}`; return `/reminders/new?${sp.toString()}`;
} }
export async function StepReview({ params }: StepReviewProps) { export async function StepReview({ params }: StepReviewProps) {
const { accountId, groupIds, text, mediaId, caption, scheduledAt, rrule } = params; const { accountId, groupIds, text, mediaId, caption, scheduledAt, rrule, editReminderId } = params;
if (!accountId || !scheduledAt || (!text && !mediaId)) { if (!accountId || !scheduledAt || (!text && !mediaId)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -104,7 +94,7 @@ export async function StepReview({ params }: StepReviewProps) {
const formattedDate = formatScheduledAt(scheduledAt, timezone); const formattedDate = formatScheduledAt(scheduledAt, timezone);
// Back goes to step 4 (Groups, the previous step in the new order) // Back goes to step 4 (Groups, the previous step in the new order)
const backHref = editLink(4, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule); const backHref = editLink(4, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule, editReminderId);
return ( return (
<div className="space-y-5"> <div className="space-y-5">
@ -127,7 +117,7 @@ export async function StepReview({ params }: StepReviewProps) {
<ReviewRow <ReviewRow
icon={<SmartphoneIcon className="size-4" />} icon={<SmartphoneIcon className="size-4" />}
label="Account" label="Account"
editHref={editLink(1, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule)} editHref={editLink(1, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule, editReminderId)}
> >
<span className="text-sm font-medium">{account.label}</span> <span className="text-sm font-medium">{account.label}</span>
{account.phoneNumber && ( {account.phoneNumber && (
@ -139,7 +129,7 @@ export async function StepReview({ params }: StepReviewProps) {
<ReviewRow <ReviewRow
icon={<FileTextIcon className="size-4" />} icon={<FileTextIcon className="size-4" />}
label="Message" label="Message"
editHref={editLink(2, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule)} editHref={editLink(2, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule, editReminderId)}
> >
{mediaId ? ( {mediaId ? (
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
@ -162,7 +152,7 @@ export async function StepReview({ params }: StepReviewProps) {
<ReviewRow <ReviewRow
icon={<CalendarIcon className="size-4" />} icon={<CalendarIcon className="size-4" />}
label={rrule ? "First fire" : "When"} label={rrule ? "First fire" : "When"}
editHref={editLink(3, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule)} editHref={editLink(3, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule, editReminderId)}
> >
<span className="text-sm font-medium">{formattedDate}</span> <span className="text-sm font-medium">{formattedDate}</span>
</ReviewRow> </ReviewRow>
@ -172,13 +162,12 @@ export async function StepReview({ params }: StepReviewProps) {
<ReviewRow <ReviewRow
icon={<RepeatIcon className="size-4" />} icon={<RepeatIcon className="size-4" />}
label="Repeats" label="Repeats"
editHref={editLink(3, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule)} editHref={editLink(3, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule, editReminderId)}
> >
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{describeRecurrence( {describeRecurrence(
kindFromRrule(rrule), specFromRrule(rrule),
DateTime.fromISO(scheduledAt!, { zone: timezone }), DateTime.fromISO(scheduledAt!, { zone: timezone }),
parseWeeklyDaysFromRrule(rrule),
)} )}
</span> </span>
</ReviewRow> </ReviewRow>
@ -188,7 +177,7 @@ export async function StepReview({ params }: StepReviewProps) {
<ReviewRow <ReviewRow
icon={<UsersIcon className="size-4" />} icon={<UsersIcon className="size-4" />}
label="Groups" label="Groups"
editHref={editLink(4, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule)} editHref={editLink(4, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule, editReminderId)}
> >
{selectedGroups.length > 0 ? ( {selectedGroups.length > 0 ? (
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
@ -217,6 +206,7 @@ export async function StepReview({ params }: StepReviewProps) {
caption={caption} caption={caption}
scheduledAt={scheduledAt} scheduledAt={scheduledAt}
rrule={rrule} rrule={rrule}
editReminderId={editReminderId}
timezone={timezone} timezone={timezone}
/> />
</div> </div>

View File

@ -1,11 +1,11 @@
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { ArrowLeftIcon } from "lucide-react"; import { ArrowLeftIcon } from "lucide-react";
import { DateTime } from "luxon";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { getSeededOperator } from "@/lib/operator"; import { getSeededOperator } from "@/lib/operator";
import { WhenFormClient } from "./when-form-client"; import { WhenFormClient } from "./when-form-client";
import { kindFromRrule } from "@/lib/recurrence"; import { specFromRrule } from "@/lib/recurrence";
import { defaultFirstFireIso } from "@/lib/date-picker";
interface StepWhenParams { interface StepWhenParams {
step?: string; step?: string;
@ -16,28 +16,15 @@ interface StepWhenParams {
caption?: string; caption?: string;
scheduledAt?: string; scheduledAt?: string;
rrule?: string; rrule?: string;
editReminderId?: string;
} }
interface StepWhenProps { interface StepWhenProps {
params: StepWhenParams; params: StepWhenParams;
} }
const WEEKDAY_TO_ISO: Record<string, number> = {
MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6, SU: 7,
};
function parseWeeklyDays(rrule: string | undefined): number[] {
if (!rrule) return [];
const m = rrule.match(/BYDAY=([A-Z,]+)/i);
if (!m) return [];
return m[1]!
.split(",")
.map((d) => WEEKDAY_TO_ISO[d.toUpperCase()])
.filter((d): d is number => d !== undefined);
}
export async function StepWhen({ params }: StepWhenProps) { export async function StepWhen({ params }: StepWhenProps) {
const { accountId, groupIds, text, mediaId, caption, scheduledAt, rrule } = params; const { accountId, groupIds, text, mediaId, caption, scheduledAt, rrule, editReminderId } = params;
if (!accountId || (!text && !mediaId)) { if (!accountId || (!text && !mediaId)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -53,6 +40,7 @@ export async function StepWhen({ params }: StepWhenProps) {
if (mediaId) backParams.set("mediaId", mediaId); if (mediaId) backParams.set("mediaId", mediaId);
if (caption) backParams.set("caption", caption); if (caption) backParams.set("caption", caption);
if (rrule) backParams.set("rrule", rrule); if (rrule) backParams.set("rrule", rrule);
if (editReminderId) backParams.set("editReminderId", editReminderId);
const backHref = `/reminders/new?${backParams.toString()}`; const backHref = `/reminders/new?${backParams.toString()}`;
return ( return (
@ -76,13 +64,9 @@ export async function StepWhen({ params }: StepWhenProps) {
accountId={accountId} accountId={accountId}
groupIds={groupIds ?? ""} groupIds={groupIds ?? ""}
timezone={timezone} timezone={timezone}
initialDefaultIso={ initialDefaultIso={scheduledAt ?? defaultFirstFireIso(timezone)}
scheduledAt ?? initialSpec={specFromRrule(rrule)}
DateTime.now().setZone(timezone).plus({ hours: 1 }).startOf("minute").toISO()! passThroughParams={{ text, mediaId, caption, editReminderId }}
}
initialKind={kindFromRrule(rrule)}
initialWeeklyDays={parseWeeklyDays(rrule)}
passThroughParams={{ text, mediaId, caption }}
/> />
</div> </div>
); );

View File

@ -11,13 +11,18 @@ import { cn } from "@/lib/utils";
import { import {
WEEKDAY_LABELS, WEEKDAY_LABELS,
buildRrule, buildRrule,
describeRecurrence,
type RecurrenceKind, type RecurrenceKind,
type RecurrenceSpec,
type EndKind,
} from "@/lib/recurrence"; } from "@/lib/recurrence";
import { splitDateTime, validateScheduledAt } from "@/lib/date-picker";
interface PassThroughParams { interface PassThroughParams {
text?: string; text?: string;
mediaId?: string; mediaId?: string;
caption?: string; caption?: string;
editReminderId?: string;
} }
interface WhenFormClientProps { interface WhenFormClientProps {
@ -25,8 +30,7 @@ interface WhenFormClientProps {
groupIds: string; groupIds: string;
timezone: string; timezone: string;
initialDefaultIso: string; initialDefaultIso: string;
initialKind?: RecurrenceKind; initialSpec?: RecurrenceSpec;
initialWeeklyDays?: number[];
passThroughParams: PassThroughParams; passThroughParams: PassThroughParams;
} }
@ -38,19 +42,19 @@ const KINDS: Array<{ value: RecurrenceKind; label: string }> = [
{ value: "yearly", label: "Yearly" }, { value: "yearly", label: "Yearly" },
]; ];
function splitDateTime(iso: string, tz: string): { date: string; time: string } { const FREQ_UNIT: Record<Exclude<RecurrenceKind, "none">, string> = {
const dt = DateTime.fromISO(iso, { zone: tz }); daily: "day",
if (!dt.isValid) return { date: "", time: "" }; weekly: "week",
return { date: dt.toFormat("yyyy-MM-dd"), time: dt.toFormat("HH:mm") }; monthly: "month",
} yearly: "year",
};
export function WhenFormClient({ export function WhenFormClient({
accountId, accountId,
groupIds, groupIds,
timezone, timezone,
initialDefaultIso, initialDefaultIso,
initialKind = "none", initialSpec,
initialWeeklyDays = [],
passThroughParams, passThroughParams,
}: WhenFormClientProps) { }: WhenFormClientProps) {
const router = useRouter(); const router = useRouter();
@ -58,8 +62,19 @@ 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>(initialKind); const [kind, setKind] = useState<RecurrenceKind>(initialSpec?.kind ?? "none");
const [weeklyDays, setWeeklyDays] = useState<number[]>(initialWeeklyDays); 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) { function toggleWeekday(iso: number) {
@ -68,21 +83,55 @@ export function WhenFormClient({
); );
} }
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,
};
}
function handleContinue() { function handleContinue() {
if (!date || !time) { const v = validateScheduledAt(date, time, timezone, Date.now());
setError("Pick both a date and a time."); if (!v.ok) {
const map: Record<typeof v.reason, string> = {
missing: "Pick both a date and a time.",
invalid: "Invalid date or time.",
past: "The first occurrence is in the past. Pick a future date and time.",
};
setError(map[v.reason]);
return; return;
} }
const dt = DateTime.fromFormat(`${date} ${time}`, "yyyy-MM-dd HH:mm", { zone: timezone }); const dt = v.dt;
if (!dt.isValid) { if (endKind === "on" && !endUntil) {
setError("Invalid date or time."); setError("Pick the end date for this recurrence.");
return; return;
} }
if (dt.toMillis() <= Date.now()) { if (endKind === "on" && endUntil) {
setError("The first occurrence is in the past. Pick a future date and time."); const until = DateTime.fromISO(endUntil, { zone: timezone });
if (until.isValid && until.toMillis() <= dt.toMillis()) {
setError("The end date must be after the first fire.");
return; return;
} }
const rrule = buildRrule(kind, dt, weeklyDays); }
if (endKind === "after" && (!Number.isFinite(endCount) || endCount < 1)) {
setError("Number of occurrences must be at least 1.");
return;
}
const spec = buildSpec(dt);
const rrule = buildRrule(spec, dt);
const scheduledAt = dt.toISO()!; const scheduledAt = dt.toISO()!;
const sp = new URLSearchParams({ const sp = new URLSearchParams({
step: "4", step: "4",
@ -94,10 +143,21 @@ export function WhenFormClient({
if (passThroughParams.text) sp.set("text", passThroughParams.text); if (passThroughParams.text) sp.set("text", passThroughParams.text);
if (passThroughParams.mediaId) sp.set("mediaId", passThroughParams.mediaId); if (passThroughParams.mediaId) sp.set("mediaId", passThroughParams.mediaId);
if (passThroughParams.caption) sp.set("caption", passThroughParams.caption); if (passThroughParams.caption) sp.set("caption", passThroughParams.caption);
if (passThroughParams.editReminderId) sp.set("editReminderId", passThroughParams.editReminderId);
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
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 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">
{/* Date + time */} {/* Date + time */}
@ -136,7 +196,7 @@ export function WhenFormClient({
</div> </div>
</div> </div>
{/* Recurrence */} {/* Frequency */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="flex items-center gap-1.5"> <Label className="flex items-center gap-1.5">
<RepeatIcon className="size-3.5" /> <RepeatIcon className="size-3.5" />
@ -165,10 +225,37 @@ export function WhenFormClient({
</div> </div>
</div> </div>
{/* Weekday picker — only for weekly */} {/* 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" && ( {kind === "weekly" && (
<div className="space-y-2"> <div className="space-y-2">
<Label>Days of the week</Label> <Label className="text-sm">On these days</Label>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{WEEKDAY_LABELS.map(({ iso, short }) => { {WEEKDAY_LABELS.map(({ iso, short }) => {
const active = weeklyDays.includes(iso); const active = weeklyDays.includes(iso);
@ -182,7 +269,7 @@ export function WhenFormClient({
"inline-flex h-8 min-w-12 items-center justify-center rounded-lg border px-2.5 text-xs font-medium transition-colors", "inline-flex h-8 min-w-12 items-center justify-center rounded-lg border px-2.5 text-xs font-medium transition-colors",
active active
? "border-primary bg-primary text-primary-foreground" ? "border-primary bg-primary text-primary-foreground"
: "border-border bg-muted/50 hover:border-primary/50 hover:bg-primary/5 hover:text-primary", : "border-border bg-background hover:border-primary/50 hover:bg-primary/5 hover:text-primary",
)} )}
> >
{short} {short}
@ -196,6 +283,105 @@ export function WhenFormClient({
</div> </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 && (
<p className="rounded-lg bg-primary/5 px-3 py-2 text-xs text-primary/80">
{previewSentence}
</p>
)}
{error && ( {error && (
<div className="flex items-center gap-1.5 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive"> <div className="flex items-center gap-1.5 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertCircleIcon className="size-3.5 shrink-0" /> <AlertCircleIcon className="size-3.5 shrink-0" />

View File

@ -0,0 +1,115 @@
import { describe, it, expect } from "vitest";
import { DateTime } from "luxon";
import {
splitDateTime,
combineDateTime,
validateScheduledAt,
defaultFirstFireIso,
} from "./date-picker";
const TZ = "Asia/Kuala_Lumpur";
describe("splitDateTime", () => {
it("splits a zoned ISO into date + time strings in that zone", () => {
// 09:00 KL is the same wall-clock no matter what offset is on the ISO.
expect(splitDateTime("2026-05-13T09:00:00+08:00", TZ)).toEqual({
date: "2026-05-13",
time: "09:00",
});
});
it("converts a UTC ISO into the operator's local wall-clock", () => {
// 2026-05-13 01:00Z = 09:00 KL.
expect(splitDateTime("2026-05-13T01:00:00Z", TZ)).toEqual({
date: "2026-05-13",
time: "09:00",
});
});
it("returns empty strings on malformed input", () => {
expect(splitDateTime("not-an-iso", TZ)).toEqual({ date: "", time: "" });
});
});
describe("combineDateTime", () => {
it("returns null when either field is missing", () => {
expect(combineDateTime("", "09:00", TZ)).toBe(null);
expect(combineDateTime("2026-05-13", "", TZ)).toBe(null);
});
it("parses a valid pair into a luxon DateTime in the right zone", () => {
const dt = combineDateTime("2026-05-13", "09:00", TZ);
expect(dt).not.toBeNull();
expect(dt!.zoneName).toBe(TZ);
// Use the offset format (timezone display varies by ICU build).
expect(dt!.toFormat("yyyy-MM-dd HH:mm ZZ")).toBe("2026-05-13 09:00 +08:00");
});
it("returns null for an unparseable pair", () => {
expect(combineDateTime("2026-99-99", "09:00", TZ)).toBe(null);
});
});
describe("validateScheduledAt", () => {
// Pin "now" so these tests are deterministic. 2026-05-13 09:00 KL.
const NOW = DateTime.fromISO("2026-05-13T09:00:00", { zone: TZ }).toMillis();
it("rejects when the date is missing", () => {
expect(validateScheduledAt("", "09:30", TZ, NOW)).toEqual({ ok: false, reason: "missing" });
});
it("rejects when the time is missing", () => {
expect(validateScheduledAt("2026-05-13", "", TZ, NOW)).toEqual({ ok: false, reason: "missing" });
});
it("rejects an invalid date+time pair", () => {
expect(validateScheduledAt("2026-99-99", "09:30", TZ, NOW)).toEqual({
ok: false,
reason: "invalid",
});
});
it("rejects timestamps clearly in the past", () => {
expect(validateScheduledAt("2026-05-13", "07:00", TZ, NOW)).toEqual({
ok: false,
reason: "past",
});
});
it("bumps a same-minute time forward by one minute (the user clicked too fast)", () => {
const r = validateScheduledAt("2026-05-13", "09:00", TZ, NOW);
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.dt.toFormat("HH:mm")).toBe("09:01");
}
});
it("accepts any future time as-is", () => {
const r = validateScheduledAt("2026-05-13", "10:30", TZ, NOW);
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.dt.toFormat("HH:mm")).toBe("10:30");
}
});
it("respects a custom grace window", () => {
// 30 seconds in the past, grace = 0 → reject.
expect(validateScheduledAt("2026-05-13", "08:59", TZ, NOW + 0, 0)).toEqual({
ok: false,
reason: "past",
});
});
});
describe("defaultFirstFireIso", () => {
it("rounds 'now' down to the start of the minute in the operator zone", () => {
const now = DateTime.fromISO("2026-05-13T09:42:37.500", { zone: "UTC" });
const iso = defaultFirstFireIso(TZ, now);
const back = DateTime.fromISO(iso, { zone: TZ });
expect(back.zoneName).toBe(TZ);
expect(back.second).toBe(0);
expect(back.millisecond).toBe(0);
// 09:42:37 UTC = 17:42:37 KL → start of minute = 17:42 KL.
expect(back.toFormat("HH:mm")).toBe("17:42");
});
});

View File

@ -0,0 +1,72 @@
import { DateTime } from "luxon";
/**
* Pure helpers for the wizard's date+time picker. Extracted so they're
* exercisable from tests without spinning up a DOM.
*/
/**
* Split an ISO timestamp into the `<input type="date">` and
* `<input type="time">` strings the picker uses, interpreted in the given
* timezone. Returns empty strings for an invalid ISO so the React inputs
* stay controlled but blank.
*/
export function splitDateTime(iso: string, tz: string): { date: string; time: string } {
const dt = DateTime.fromISO(iso, { zone: tz });
if (!dt.isValid) return { date: "", time: "" };
return { date: dt.toFormat("yyyy-MM-dd"), time: dt.toFormat("HH:mm") };
}
/**
* Parse the picker fields back into a luxon DateTime in the operator's zone.
* Returns null if either field is empty or the combination doesn't parse.
*/
export function combineDateTime(date: string, time: string, tz: string): DateTime | null {
if (!date || !time) return null;
const dt = DateTime.fromFormat(`${date} ${time}`, "yyyy-MM-dd HH:mm", { zone: tz });
return dt.isValid ? dt : null;
}
export type DateTimeValidation =
| { ok: true; dt: DateTime }
| { ok: false; reason: "missing" | "invalid" | "past" };
/**
* Validate a (date, time) pair as a "first occurrence" timestamp.
*
* The default value the wizard pre-fills is the *current minute* in the
* operator's timezone. The user might click Continue immediately, before
* that minute ticks over `dt` would be now by a few seconds, but the
* intent is "now". This helper bumps anything within `nowGraceMs` of now
* to the next minute rather than rejecting it. Beyond that grace window
* we treat it as a real "in the past" mistake.
*
* `now` is injected so tests can pin it.
*/
export function validateScheduledAt(
date: string,
time: string,
tz: string,
now: number,
nowGraceMs = 60_000,
): DateTimeValidation {
if (!date || !time) return { ok: false, reason: "missing" };
const dt = combineDateTime(date, time, tz);
if (!dt) return { ok: false, reason: "invalid" };
if (dt.toMillis() <= now) {
if (now - dt.toMillis() <= nowGraceMs) {
return { ok: true, dt: dt.plus({ minutes: 1 }) };
}
return { ok: false, reason: "past" };
}
return { ok: true, dt };
}
/**
* Compute the wizard's default first-fire ISO. Server-rendered so SSR and
* client first-render agree (no hydration mismatch from a stray `Date.now()`
* inside the client component).
*/
export function defaultFirstFireIso(tz: string, now: DateTime = DateTime.now()): string {
return now.setZone(tz).startOf("minute").toISO()!;
}

View File

@ -93,7 +93,7 @@ export async function getGroup(operatorId: string, groupId: string) {
export async function listReminders(operatorId: string) { export async function listReminders(operatorId: string) {
const rows = await db.execute(sql` const rows = await db.execute(sql`
SELECT SELECT
r.id, r.name, r.schedule_kind, r.scheduled_at, r.timezone, r.status, r.id, r.name, r.schedule_kind, r.scheduled_at, r.rrule, r.timezone, r.status,
r.created_at, wa.label as account_label, r.created_at, wa.label as account_label,
(SELECT count(*) FROM reminder_targets rt WHERE rt.reminder_id = r.id) as group_count (SELECT count(*) FROM reminder_targets rt WHERE rt.reminder_id = r.id) as group_count
FROM reminders r FROM reminders r
@ -107,6 +107,7 @@ export async function listReminders(operatorId: string) {
name: r.name as string, name: r.name as string,
scheduleKind: r.schedule_kind as string, scheduleKind: r.schedule_kind as string,
scheduledAt: r.scheduled_at as Date | null, scheduledAt: r.scheduled_at as Date | null,
rrule: (r.rrule as string | null) ?? null,
timezone: r.timezone as string, timezone: r.timezone as string,
status: r.status as string, status: r.status as string,
createdAt: r.created_at as Date, createdAt: r.created_at as Date,

View File

@ -4,64 +4,207 @@ import {
buildRrule, buildRrule,
describeRecurrence, describeRecurrence,
kindFromRrule, kindFromRrule,
specFromRrule,
type RecurrenceSpec,
} from "./recurrence"; } from "./recurrence";
const FIRST = DateTime.fromISO("2026-05-13T09:00:00", { zone: "Asia/Kuala_Lumpur" }); const FIRST = DateTime.fromISO("2026-05-13T09:00:00", { zone: "Asia/Kuala_Lumpur" });
const baseSpec = (over: Partial<RecurrenceSpec> = {}): RecurrenceSpec => ({
kind: "none",
interval: 1,
weeklyDays: [],
end: { kind: "never" },
...over,
});
describe("buildRrule", () => { describe("buildRrule", () => {
it("returns null for one-off", () => { it("returns null for one-off", () => {
expect(buildRrule("none", FIRST, [])).toBe(null); expect(buildRrule(baseSpec({ kind: "none" }), FIRST)).toBe(null);
}); });
it("daily → FREQ=DAILY", () => { it("daily simple", () => {
expect(buildRrule("daily", FIRST, [])).toBe("FREQ=DAILY"); expect(buildRrule(baseSpec({ kind: "daily" }), FIRST)).toBe("FREQ=DAILY");
}); });
it("weekly with explicit days uses BYDAY in MO,TU,WE,TH,FR,SA,SU order", () => { it("daily with interval", () => {
// Pass days in mixed order — should be sorted by ISO weekday number expect(buildRrule(baseSpec({ kind: "daily", interval: 3 }), FIRST)).toBe(
expect(buildRrule("weekly", FIRST, [3, 1, 5])).toBe("FREQ=WEEKLY;BYDAY=MO,WE,FR"); "FREQ=DAILY;INTERVAL=3",
);
}); });
it("weekly with no days falls back to first-fire weekday", () => { it("weekly with explicit days sorts to canonical order", () => {
// 2026-05-13 is a Wednesday in luxon ISO weekday → 3 expect(
expect(buildRrule("weekly", FIRST, [])).toBe("FREQ=WEEKLY;BYDAY=WE"); buildRrule(baseSpec({ kind: "weekly", weeklyDays: [3, 1, 5] }), FIRST),
).toBe("FREQ=WEEKLY;BYDAY=MO,WE,FR");
}); });
it("monthly uses BYMONTHDAY of the first-fire date", () => { it("weekly with no days falls back to first-fire weekday (Wed)", () => {
expect(buildRrule("monthly", FIRST, [])).toBe("FREQ=MONTHLY;BYMONTHDAY=13"); expect(buildRrule(baseSpec({ kind: "weekly" }), FIRST)).toBe("FREQ=WEEKLY;BYDAY=WE");
});
it("monthly defaults to first-fire day-of-month", () => {
expect(buildRrule(baseSpec({ kind: "monthly" }), FIRST)).toBe(
"FREQ=MONTHLY;BYMONTHDAY=13",
);
});
it("monthly honours explicit monthDay", () => {
expect(buildRrule(baseSpec({ kind: "monthly", monthDay: 1 }), FIRST)).toBe(
"FREQ=MONTHLY;BYMONTHDAY=1",
);
}); });
it("yearly uses BYMONTH and BYMONTHDAY", () => { it("yearly uses BYMONTH and BYMONTHDAY", () => {
expect(buildRrule("yearly", FIRST, [])).toBe("FREQ=YEARLY;BYMONTH=5;BYMONTHDAY=13"); expect(buildRrule(baseSpec({ kind: "yearly" }), FIRST)).toBe(
"FREQ=YEARLY;BYMONTH=5;BYMONTHDAY=13",
);
});
it("end=after attaches COUNT", () => {
expect(
buildRrule(
baseSpec({ kind: "daily", end: { kind: "after", count: 7 } }),
FIRST,
),
).toBe("FREQ=DAILY;COUNT=7");
});
it("end=on attaches UNTIL in UTC", () => {
const r = buildRrule(
baseSpec({ kind: "daily", end: { kind: "on", until: "2026-06-01" } }),
FIRST,
);
expect(r).toMatch(/^FREQ=DAILY;UNTIL=2026060[0-2]T235959Z$/);
});
it("interval + weekly + count compose correctly", () => {
expect(
buildRrule(
baseSpec({
kind: "weekly",
interval: 2,
weeklyDays: [1, 3, 5],
end: { kind: "after", count: 12 },
}),
FIRST,
),
).toBe("FREQ=WEEKLY;BYDAY=MO,WE,FR;INTERVAL=2;COUNT=12");
}); });
}); });
describe("kindFromRrule", () => { describe("specFromRrule / kindFromRrule", () => {
it("recognises every supported FREQ", () => { it("returns the default spec for null/undefined", () => {
expect(kindFromRrule(null)).toBe("none"); expect(specFromRrule(null)).toEqual({
kind: "none",
interval: 1,
weeklyDays: [],
monthDay: undefined,
end: { kind: "never" },
});
expect(kindFromRrule(undefined)).toBe("none"); expect(kindFromRrule(undefined)).toBe("none");
expect(kindFromRrule("FREQ=DAILY")).toBe("daily");
expect(kindFromRrule("FREQ=WEEKLY;BYDAY=MO,WE,FR")).toBe("weekly");
expect(kindFromRrule("FREQ=MONTHLY;BYMONTHDAY=13")).toBe("monthly");
expect(kindFromRrule("FREQ=YEARLY;BYMONTH=5;BYMONTHDAY=13")).toBe("yearly");
}); });
it("is case-insensitive", () => { it("parses daily with interval", () => {
expect(kindFromRrule("freq=daily")).toBe("daily"); expect(specFromRrule("FREQ=DAILY;INTERVAL=3")).toEqual({
kind: "daily",
interval: 3,
weeklyDays: [],
monthDay: undefined,
end: { kind: "never" },
});
}); });
it("returns 'none' for an unrecognised rule", () => { it("parses weekly with BYDAY", () => {
expect(kindFromRrule("FREQ=HOURLY")).toBe("none"); expect(specFromRrule("FREQ=WEEKLY;BYDAY=MO,WE,FR")).toMatchObject({
kind: "weekly",
weeklyDays: [1, 3, 5],
});
});
it("parses monthly with BYMONTHDAY", () => {
expect(specFromRrule("FREQ=MONTHLY;BYMONTHDAY=15")).toMatchObject({
kind: "monthly",
monthDay: 15,
});
});
it("parses COUNT into end=after", () => {
expect(specFromRrule("FREQ=DAILY;COUNT=10").end).toEqual({
kind: "after",
count: 10,
});
});
it("parses UNTIL into end=on (date only)", () => {
expect(specFromRrule("FREQ=DAILY;UNTIL=20260601T235959Z").end).toEqual({
kind: "on",
until: "2026-06-01",
});
});
it("round-trips through buildRrule + specFromRrule for compound rules", () => {
const spec = baseSpec({
kind: "weekly",
interval: 2,
weeklyDays: [1, 3, 5],
end: { kind: "after", count: 12 },
});
const rule = buildRrule(spec, FIRST)!;
expect(specFromRrule(rule)).toMatchObject({
kind: "weekly",
interval: 2,
weeklyDays: [1, 3, 5],
end: { kind: "after", count: 12 },
});
}); });
}); });
describe("describeRecurrence", () => { describe("describeRecurrence", () => {
it("renders human-readable summaries", () => { it("renders a one-off label", () => {
expect(describeRecurrence("none", FIRST, [])).toBe("One-off"); expect(describeRecurrence(baseSpec({ kind: "none" }), FIRST)).toBe("One-off");
expect(describeRecurrence("daily", FIRST, [])).toBe("Every day"); });
expect(describeRecurrence("weekly", FIRST, [1, 3, 5])).toBe("Every Mon, Wed, Fri");
expect(describeRecurrence("weekly", FIRST, [])).toBe("Every Wed"); it("renders interval and unit pluralisation", () => {
expect(describeRecurrence("monthly", FIRST, [])).toBe("Every month on day 13"); expect(describeRecurrence(baseSpec({ kind: "daily" }), FIRST)).toBe("Every day");
expect(describeRecurrence("yearly", FIRST, [])).toBe("Every year on May 13"); expect(describeRecurrence(baseSpec({ kind: "daily", interval: 2 }), FIRST)).toBe(
"Every 2 days",
);
});
it("renders weekly days as Short labels in canonical order", () => {
expect(
describeRecurrence(baseSpec({ kind: "weekly", weeklyDays: [5, 1, 3] }), FIRST),
).toBe("Every week on Mon, Wed, Fri");
});
it("renders monthly with day", () => {
expect(describeRecurrence(baseSpec({ kind: "monthly", monthDay: 14 }), FIRST)).toBe(
"Every month on day 14",
);
});
it("renders yearly with month and day", () => {
expect(describeRecurrence(baseSpec({ kind: "yearly" }), FIRST)).toBe(
"Every year on May 13",
);
});
it("appends end=after as ', N times'", () => {
expect(
describeRecurrence(
baseSpec({ kind: "daily", end: { kind: "after", count: 5 } }),
FIRST,
),
).toBe("Every day, 5 times");
});
it("appends end=on as ', until <date>'", () => {
expect(
describeRecurrence(
baseSpec({ kind: "daily", end: { kind: "on", until: "2026-06-01" } }),
FIRST,
),
).toBe("Every day, until 2026-06-01");
}); });
}); });

View File

@ -1,8 +1,8 @@
import { DateTime } from "luxon"; import { DateTime } from "luxon";
export type RecurrenceKind = "none" | "daily" | "weekly" | "monthly" | "yearly"; export type RecurrenceKind = "none" | "daily" | "weekly" | "monthly" | "yearly";
export type EndKind = "never" | "after" | "on";
/** ISO weekday → RRULE day code. Luxon weekday: 1=Mon ... 7=Sun. */
const WEEKDAY_CODES = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"] as const; const WEEKDAY_CODES = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"] as const;
export const WEEKDAY_LABELS: Array<{ iso: number; code: string; short: string; long: string }> = [ export const WEEKDAY_LABELS: Array<{ iso: number; code: string; short: string; long: string }> = [
@ -15,77 +15,182 @@ export const WEEKDAY_LABELS: Array<{ iso: number; code: string; short: string; l
{ iso: 7, code: "SU", short: "Sun", long: "Sunday" }, { iso: 7, code: "SU", short: "Sun", long: "Sunday" },
]; ];
export interface RecurrenceSpec {
kind: RecurrenceKind;
/** Every N units. Defaults to 1. Ignored for `none`. */
interval: number;
/** ISO weekday numbers (1=Mon..7=Sun). Used for `weekly`. */
weeklyDays: number[];
/** Day-of-month for `monthly` (1-31). If omitted, falls back to firstFire.day. */
monthDay?: number;
/** End condition. */
end:
| { kind: "never" }
| { kind: "after"; count: number }
| { kind: "on"; until: string /* ISO date YYYY-MM-DD */ };
}
export const DEFAULT_RECURRENCE: RecurrenceSpec = {
kind: "none",
interval: 1,
weeklyDays: [],
end: { kind: "never" },
};
function clampInterval(n: number): number {
if (!Number.isFinite(n) || n < 1) return 1;
return Math.floor(n);
}
/** /**
* Build an RRULE for the given recurrence pattern. Returns null for "none" * Build an RRULE string. Supports interval, weekday list, monthday, and the
* (one-off reminders don't carry an RRULE). * end condition (COUNT or UNTIL). Returns null for one-off reminders.
*
* For weekly with no weekdays selected, falls back to the weekday of
* `firstFire` so the rule is always concrete.
*/ */
export function buildRrule( export function buildRrule(spec: RecurrenceSpec, firstFire: DateTime): string | null {
kind: RecurrenceKind, if (spec.kind === "none") return null;
firstFire: DateTime,
weeklyDays: number[], const parts: string[] = [];
): string | null { switch (spec.kind) {
switch (kind) {
case "none":
return null;
case "daily": case "daily":
return "FREQ=DAILY"; parts.push("FREQ=DAILY");
break;
case "weekly": { case "weekly": {
parts.push("FREQ=WEEKLY");
const days = const days =
weeklyDays.length > 0 spec.weeklyDays.length > 0
? weeklyDays ? spec.weeklyDays
: [firstFire.weekday]; : [firstFire.weekday];
const codes = days const codes = days
.slice() .slice()
.sort((a, b) => a - b) .sort((a, b) => a - b)
.map((d) => WEEKDAY_CODES[d - 1]) .map((d) => WEEKDAY_CODES[d - 1])
.filter(Boolean); .filter(Boolean);
return `FREQ=WEEKLY;BYDAY=${codes.join(",")}`; parts.push(`BYDAY=${codes.join(",")}`);
break;
} }
case "monthly": case "monthly":
return `FREQ=MONTHLY;BYMONTHDAY=${firstFire.day}`; parts.push("FREQ=MONTHLY");
parts.push(`BYMONTHDAY=${spec.monthDay ?? firstFire.day}`);
break;
case "yearly": case "yearly":
return `FREQ=YEARLY;BYMONTH=${firstFire.month};BYMONTHDAY=${firstFire.day}`; parts.push("FREQ=YEARLY");
parts.push(`BYMONTH=${firstFire.month}`);
parts.push(`BYMONTHDAY=${firstFire.day}`);
break;
} }
const interval = clampInterval(spec.interval);
if (interval !== 1) parts.push(`INTERVAL=${interval}`);
if (spec.end.kind === "after" && spec.end.count > 0) {
parts.push(`COUNT=${Math.floor(spec.end.count)}`);
} else if (spec.end.kind === "on" && spec.end.until) {
// RRULE UNTIL is a UTC timestamp. Translate the user's "on this date"
// into 23:59:59 UTC of that day so the last occurrence is included.
const dt = DateTime.fromISO(`${spec.end.until}T23:59:59`, { zone: "utc" });
if (dt.isValid) {
parts.push(`UNTIL=${dt.toFormat("yyyyMMdd'T'HHmmss'Z'")}`);
}
}
return parts.join(";");
} }
/** Human-readable summary, e.g. "Every Mon, Wed" or "Every month on the 14th". */ const FREQ_UNIT: Record<string, string> = {
export function describeRecurrence( daily: "day",
kind: RecurrenceKind, weekly: "week",
firstFire: DateTime, monthly: "month",
weeklyDays: number[], yearly: "year",
): string { };
switch (kind) {
case "none": /**
return "One-off"; * Render the spec as a human sentence, e.g.
case "daily": * "Every day"
return "Every day"; * "Every 2 weeks on Mon, Wed, Fri"
case "weekly": { * "Every month on day 14, 12 times"
const days = weeklyDays.length > 0 ? weeklyDays : [firstFire.weekday]; * "Every year on May 13, until 2027-05-13"
*/
export function describeRecurrence(spec: RecurrenceSpec, firstFire: DateTime): string {
if (spec.kind === "none") return "One-off";
const interval = clampInterval(spec.interval);
const unit = FREQ_UNIT[spec.kind]!;
const head =
interval === 1 ? `Every ${unit}` : `Every ${interval} ${unit}s`;
let body = "";
if (spec.kind === "weekly") {
const days = spec.weeklyDays.length > 0 ? spec.weeklyDays : [firstFire.weekday];
const labels = days const labels = days
.slice() .slice()
.sort((a, b) => a - b) .sort((a, b) => a - b)
.map((d) => WEEKDAY_LABELS[d - 1]?.short) .map((d) => WEEKDAY_LABELS[d - 1]?.short)
.filter(Boolean) .filter(Boolean)
.join(", "); .join(", ");
return `Every ${labels}`; body = ` on ${labels}`;
} else if (spec.kind === "monthly") {
body = ` on day ${spec.monthDay ?? firstFire.day}`;
} else if (spec.kind === "yearly") {
body = ` on ${firstFire.toFormat("MMM d")}`;
} }
case "monthly":
return `Every month on day ${firstFire.day}`; let tail = "";
case "yearly": if (spec.end.kind === "after" && spec.end.count > 0) {
return `Every year on ${firstFire.toFormat("MMM d")}`; tail = `, ${Math.floor(spec.end.count)} time${spec.end.count === 1 ? "" : "s"}`;
} else if (spec.end.kind === "on" && spec.end.until) {
tail = `, until ${spec.end.until}`;
} }
return head + body + tail;
} }
/** Parse the kind back from an RRULE string (best-effort, for review display). */ /** Parse a stored RRULE back into a spec for resuming the wizard / editing. */
export function kindFromRrule(rrule: string | null | undefined): RecurrenceKind { export function specFromRrule(rrule: string | null | undefined): RecurrenceSpec {
if (!rrule) return "none"; if (!rrule) return { ...DEFAULT_RECURRENCE };
const upper = rrule.toUpperCase();
if (upper.includes("FREQ=DAILY")) return "daily"; const tokens = rrule
if (upper.includes("FREQ=WEEKLY")) return "weekly"; .split(";")
if (upper.includes("FREQ=MONTHLY")) return "monthly"; .map((t) => t.trim())
if (upper.includes("FREQ=YEARLY")) return "yearly"; .filter(Boolean)
return "none"; .reduce<Record<string, string>>((acc, t) => {
const [k, v] = t.split("=");
if (k && v !== undefined) acc[k.toUpperCase()] = v;
return acc;
}, {});
const freq = (tokens.FREQ ?? "").toUpperCase();
let kind: RecurrenceKind = "none";
if (freq === "DAILY") kind = "daily";
else if (freq === "WEEKLY") kind = "weekly";
else if (freq === "MONTHLY") kind = "monthly";
else if (freq === "YEARLY") kind = "yearly";
const interval = tokens.INTERVAL ? clampInterval(Number(tokens.INTERVAL)) : 1;
const weeklyDays: number[] = [];
if (tokens.BYDAY) {
for (const code of tokens.BYDAY.split(",")) {
const idx = WEEKDAY_CODES.indexOf(code.toUpperCase() as (typeof WEEKDAY_CODES)[number]);
if (idx >= 0) weeklyDays.push(idx + 1);
}
}
const monthDay = tokens.BYMONTHDAY ? Number(tokens.BYMONTHDAY) || undefined : undefined;
let end: RecurrenceSpec["end"] = { kind: "never" };
if (tokens.COUNT) {
const n = Number(tokens.COUNT);
if (Number.isFinite(n) && n > 0) end = { kind: "after", count: Math.floor(n) };
} else if (tokens.UNTIL) {
// UNTIL is `YYYYMMDDTHHMMSSZ` per RFC. Pull the date.
const m = tokens.UNTIL.match(/^(\d{4})(\d{2})(\d{2})/);
if (m) end = { kind: "on", until: `${m[1]}-${m[2]}-${m[3]}` };
}
return { kind, interval, weeklyDays, monthDay, end };
}
/** Backwards-compatible helper for callers that only need the kind. */
export function kindFromRrule(rrule: string | null | undefined): RecurrenceKind {
return specFromRrule(rrule).kind;
} }