diff --git a/apps/bot/src/ipc/pair-handler.ts b/apps/bot/src/ipc/pair-handler.ts index 2add1fa..45c5464 100644 --- a/apps/bot/src/ipc/pair-handler.ts +++ b/apps/bot/src/ipc/pair-handler.ts @@ -14,8 +14,13 @@ import { pgNotifyWeb } from "./notify.js"; const PAIR_TIMEOUT_MS = 5 * 60 * 1000; const offByAccount = new Map void>(); const lastQrPayload = new Map(); +const lastQrEmitMs = new Map(); const pairTimeouts = new Map(); +// 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 }> { const account = await db.query.whatsappAccounts.findFirst({ where: (a, { eq }) => eq(a.id, accountId), @@ -51,6 +56,16 @@ export async function handleStartPairing(accountId: string): Promise { 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 // sessionManager.start() actually opens a fresh socket and Baileys emits // 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 { } // Clear any stale QR lingering from a prior attempt. lastQrPayload.delete(accountId); + lastQrEmitMs.delete(accountId); await db .update(whatsappAccounts) .set({ lastQrPng: null }) @@ -69,7 +85,15 @@ export async function handleStartPairing(accountId: string): Promise { try { if (event.type === "qr") { 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); + lastQrEmitMs.set(id, now); const png = await renderQrPng(event.payload); // PNG is too large (~5-10KB) for pg_notify (8000 byte limit). // Persist on the account row; web fetches via /api/qr/[id]. @@ -80,7 +104,7 @@ export async function handleStartPairing(accountId: string): Promise { await pgNotifyWeb({ type: "session.qr", accountId: id, - ts: Date.now(), + ts: now, }); } else if (event.type === "open") { const t = pairTimeouts.get(id); @@ -89,6 +113,7 @@ export async function handleStartPairing(accountId: string): Promise { pairTimeouts.delete(id); } lastQrPayload.delete(id); + lastQrEmitMs.delete(id); offByAccount.delete(id); const session = sessionManager.getSession(id); let synced = 0; @@ -122,6 +147,7 @@ export async function handleStartPairing(accountId: string): Promise { pairTimeouts.delete(id); } lastQrPayload.delete(id); + lastQrEmitMs.delete(id); offByAccount.delete(id); await pgNotifyWeb({ type: "session.timeout", accountId: id }); off(); diff --git a/apps/web/src/actions/reminders.ts b/apps/web/src/actions/reminders.ts index ee6a661..8eee523 100644 --- a/apps/web/src/actions/reminders.ts +++ b/apps/web/src/actions/reminders.ts @@ -149,3 +149,119 @@ export async function createReminderAction( 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, +): Promise { + 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: so this supersedes the prior arming. + await pgNotifyBot({ + type: "reminder.schedule", + reminderId, + scheduledAtIso: scheduledAt.toISOString(), + }); + + revalidatePath("/reminders"); + revalidatePath(`/reminders/${reminderId}`); + return { ok: true, reminderId }; +} diff --git a/apps/web/src/app/api/qr/[accountId]/route.test.ts b/apps/web/src/app/api/qr/[accountId]/route.test.ts new file mode 100644 index 0000000..6a28bdf --- /dev/null +++ b/apps/web/src/app/api/qr/[accountId]/route.test.ts @@ -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]); + }); +}); diff --git a/apps/web/src/app/reminders/[id]/edit/page.tsx b/apps/web/src/app/reminders/[id]/edit/page.tsx new file mode 100644 index 0000000..02e4d20 --- /dev/null +++ b/apps/web/src/app/reminders/[id]/edit/page.tsx @@ -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); +} diff --git a/apps/web/src/app/reminders/[id]/page.tsx b/apps/web/src/app/reminders/[id]/page.tsx index 8500e9e..4f438b4 100644 --- a/apps/web/src/app/reminders/[id]/page.tsx +++ b/apps/web/src/app/reminders/[id]/page.tsx @@ -7,15 +7,16 @@ import { UsersIcon, ClockIcon, FileTextIcon, + RepeatIcon, + PencilIcon, } from "lucide-react"; +import { DateTime } from "luxon"; +import { describeRecurrence, specFromRrule } from "@/lib/recurrence"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, - CardHeader, - CardTitle, - CardDescription, } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; import { @@ -30,9 +31,6 @@ import { getSeededOperator } from "@/lib/operator"; import { getReminderWithRuns } from "@/lib/queries"; import { DeleteDialog } from "./delete-dialog"; -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- function formatWhen(date: Date | null, tz: string): string { if (!date) return "—"; return new Intl.DateTimeFormat("en-MY", { @@ -45,9 +43,6 @@ function formatWhen(date: Date | null, tz: string): string { }).format(new Date(date)); } -// --------------------------------------------------------------------------- -// Status pill -// --------------------------------------------------------------------------- const STATUS_STYLES: Record = { active: "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 { params: Promise<{ id: string }>; } @@ -94,9 +85,40 @@ export default async function ReminderDetailPage({ params }: Props) { const { reminder, account, targets, messages, runs } = data; 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 (
- {/* Back link */} - {/* Header */}

@@ -113,76 +134,136 @@ export default async function ReminderDetailPage({ params }: Props) {

-
-
- - When: {formatWhen(reminder.scheduledAt, tz)} -
-
- - Account: {account.label} -
-
+

+ Tap any section below to edit it. +

- {/* Message body */} -
-

- - Message -

- - - {messages.length === 0 ? ( -

No message parts defined.

- ) : ( - messages.map((msg, i) => ( -
- {i > 0 && } - {msg.kind === "text" && msg.textContent ? ( -

{msg.textContent}

- ) : ( -
+ {/* Account — click to edit step 1 */} + {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} + + + +
+ +
+
+

+ Account +

+

{account.label}

+ {account.phoneNumber && ( +

{account.phoneNumber}

+ )} +
+ +
+
+ + + {/* Message — click to edit step 2 */} + {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} + + + +
+ +
+
+

+ Message +

+ {messages.length === 0 ? ( +

No message parts defined.

+ ) : ( + messages.map((msg, i) => ( +
+ {i > 0 && } + {msg.kind === "text" && msg.textContent ? ( +

+ {msg.textContent} +

+ ) : (

[{msg.kind}]{msg.textContent ? ` ${msg.textContent}` : ""}

-

- Media preview coming soon. -

-
- )} -
- )) - )} + )} +
+ )) + )} +
+
-
+ - {/* Target groups */} - {targets.length > 0 && ( -
-

- - Groups - - {targets.length} - -

-
- {targets.map((t) => ( - - {t.groupName} - - ))} -
-
- )} + {/* When / Recurrence — click to edit step 3 */} + {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} + + + +
+ +
+
+

+ {reminder.rrule ? "First fire" : "When"} +

+

{formatWhen(reminder.scheduledAt, tz)}

+ {reminder.rrule && reminder.scheduledAt ? ( +

+ + {describeRecurrence( + specFromRrule(reminder.rrule), + DateTime.fromJSDate(reminder.scheduledAt).setZone(reminder.timezone), + )} +

+ ) : ( +

One-off

+ )} +
+ +
+
+ + + {/* Groups — click to edit step 4 */} + {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} + + + +
+ +
+
+

+ Groups + {targets.length > 0 ? ` · ${targets.length}` : " · none"} +

+ {targets.length === 0 ? ( +

+ No groups — reminder won't deliver until you add at least one +

+ ) : ( +
+ {targets.map((t) => ( + + {t.groupName} + + ))} +
+ )} +
+ +
+
+ - {/* Run history */} + {/* Run history — read-only */}

@@ -233,8 +314,8 @@ export default async function ReminderDetailPage({ params }: Props) { )}

- {/* Action footer */} -
+ {/* Action footer — Delete only; section cards above handle editing */} +
diff --git a/apps/web/src/app/reminders/new/page.tsx b/apps/web/src/app/reminders/new/page.tsx index 6d5e434..6ff4716 100644 --- a/apps/web/src/app/reminders/new/page.tsx +++ b/apps/web/src/app/reminders/new/page.tsx @@ -15,7 +15,9 @@ interface PageProps { mediaId?: string; caption?: string; scheduledAt?: string; + rrule?: string; groupId?: string; + editReminderId?: string; }>; } @@ -23,10 +25,13 @@ export default async function NewReminderPage({ searchParams }: PageProps) { const sp = await searchParams; const step = Number(sp.step ?? "1"); if (![1, 2, 3, 4, 5].includes(step)) notFound(); + const isEdit = Boolean(sp.editReminderId); return (
-

New Reminder

+

+ {isEdit ? "Edit Reminder" : "New Reminder"} +

{step === 1 && } {step === 2 && } diff --git a/apps/web/src/app/reminders/page.tsx b/apps/web/src/app/reminders/page.tsx index ba2f224..49a39cd 100644 --- a/apps/web/src/app/reminders/page.tsx +++ b/apps/web/src/app/reminders/page.tsx @@ -1,11 +1,13 @@ 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 { Badge } from "@/components/ui/badge"; import { Card, CardContent } from "@/components/ui/card"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { getSeededOperator } from "@/lib/operator"; import { listReminders } from "@/lib/queries"; +import { describeRecurrence, specFromRrule } from "@/lib/recurrence"; // --------------------------------------------------------------------------- // Types @@ -138,12 +140,23 @@ export default async function RemindersPage({ searchParams }: PageProps) {

- {/* When + group count */} + {/* When + recurrence + group count */}
{formatWhen(reminder.scheduledAt, tz)}
+ {reminder.rrule && reminder.scheduledAt ? ( +
+ + + {describeRecurrence( + specFromRrule(reminder.rrule), + DateTime.fromJSDate(reminder.scheduledAt).setZone(reminder.timezone), + )} + +
+ ) : null} {reminder.groupCount > 0 && (
@@ -164,18 +177,26 @@ export default async function RemindersPage({ searchParams }: PageProps) {
-

No reminders yet.

+

+ {filter === "all" + ? "No reminders yet." + : `No ${filter} reminders yet.`} +

- 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.`}

- + {allReminders.length === 0 && ( + + )}
)} diff --git a/apps/web/src/components/reminder-wizard/compose-form-client.tsx b/apps/web/src/components/reminder-wizard/compose-form-client.tsx index 810377c..af57530 100644 --- a/apps/web/src/components/reminder-wizard/compose-form-client.tsx +++ b/apps/web/src/components/reminder-wizard/compose-form-client.tsx @@ -19,6 +19,8 @@ import { cn } from "@/lib/utils"; interface PassThroughParams { scheduledAt?: string; + rrule?: string; + editReminderId?: string; } interface ComposeFormClientProps { @@ -118,6 +120,8 @@ export function ComposeFormClient({ if (mediaId) sp.set("mediaId", mediaId); if (caption.trim()) sp.set("caption", caption.trim()); 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 router.push(`/reminders/new?${sp.toString()}` as any); } diff --git a/apps/web/src/components/reminder-wizard/groups-form-client.tsx b/apps/web/src/components/reminder-wizard/groups-form-client.tsx index a065e99..a6e117d 100644 --- a/apps/web/src/components/reminder-wizard/groups-form-client.tsx +++ b/apps/web/src/components/reminder-wizard/groups-form-client.tsx @@ -20,6 +20,7 @@ interface PassThroughParams { caption?: string; scheduledAt?: string; rrule?: string; + editReminderId?: string; } interface GroupsFormClientProps { @@ -72,6 +73,7 @@ export function GroupsFormClient({ if (passThroughParams.caption) sp.set("caption", passThroughParams.caption); 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 router.push(`/reminders/new?${sp.toString()}` as any); } diff --git a/apps/web/src/components/reminder-wizard/review-submit-client.tsx b/apps/web/src/components/reminder-wizard/review-submit-client.tsx index 8cebb2b..72e073d 100644 --- a/apps/web/src/components/reminder-wizard/review-submit-client.tsx +++ b/apps/web/src/components/reminder-wizard/review-submit-client.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { CalendarCheckIcon, AlertCircleIcon, Loader2Icon } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { createReminderAction } from "@/actions/reminders"; +import { createReminderAction, updateReminderAction } from "@/actions/reminders"; import { cn } from "@/lib/utils"; interface ReviewSubmitClientProps { @@ -15,6 +15,7 @@ interface ReviewSubmitClientProps { caption?: string; scheduledAt: string; rrule?: string; + editReminderId?: string; timezone: string; } @@ -26,6 +27,7 @@ export function ReviewSubmitClient({ caption, scheduledAt, rrule, + editReminderId, timezone, }: ReviewSubmitClientProps) { const router = useRouter(); @@ -37,7 +39,7 @@ export function ReviewSubmitClient({ setError(null); try { - const result = await createReminderAction({ + const payload = { accountId, groupIds: groupIds ? groupIds.split(",").filter(Boolean) : [], text: text ?? null, @@ -46,7 +48,10 @@ export function ReviewSubmitClient({ scheduledAtIso: scheduledAt, rrule: rrule ?? null, timezone, - }); + }; + const result = editReminderId + ? await updateReminderAction({ ...payload, reminderId: editReminderId }) + : await createReminderAction(payload); if (result.ok) { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -81,12 +86,12 @@ export function ReviewSubmitClient({ {submitting ? ( <> - Scheduling… + {editReminderId ? "Saving…" : "Scheduling…"} ) : ( <> - Schedule Reminder + {editReminderId ? "Save changes" : "Schedule Reminder"} )} diff --git a/apps/web/src/components/reminder-wizard/step-compose.tsx b/apps/web/src/components/reminder-wizard/step-compose.tsx index 4641764..5f339c1 100644 --- a/apps/web/src/components/reminder-wizard/step-compose.tsx +++ b/apps/web/src/components/reminder-wizard/step-compose.tsx @@ -12,6 +12,8 @@ interface StepComposeParams { mediaId?: string; caption?: string; scheduledAt?: string; + rrule?: string; + editReminderId?: string; } interface StepComposeProps { @@ -52,6 +54,8 @@ export function StepCompose({ params }: StepComposeProps) { initialCaption={caption} passThroughParams={{ scheduledAt: params.scheduledAt, + rrule: params.rrule, + editReminderId: params.editReminderId, }} />
diff --git a/apps/web/src/components/reminder-wizard/step-groups.tsx b/apps/web/src/components/reminder-wizard/step-groups.tsx index cf88506..69c9c7b 100644 --- a/apps/web/src/components/reminder-wizard/step-groups.tsx +++ b/apps/web/src/components/reminder-wizard/step-groups.tsx @@ -16,6 +16,7 @@ interface StepGroupsParams { scheduledAt?: string; rrule?: string; groupId?: string; + editReminderId?: string; } interface StepGroupsProps { @@ -31,6 +32,7 @@ export async function StepGroups({ params }: StepGroupsProps) { text, mediaId, rrule, + editReminderId, } = params; if (!accountId || !scheduledAt || (!text && !mediaId)) { @@ -74,6 +76,7 @@ export async function StepGroups({ params }: StepGroupsProps) { if (params.caption) backParams.set("caption", params.caption); if (scheduledAt) backParams.set("scheduledAt", scheduledAt); if (rrule) backParams.set("rrule", rrule); + if (editReminderId) backParams.set("editReminderId", editReminderId); const backHref = `/reminders/new?${backParams.toString()}`; return ( @@ -103,6 +106,7 @@ export async function StepGroups({ params }: StepGroupsProps) { caption: params.caption, scheduledAt: params.scheduledAt, rrule, + editReminderId, }} />
@@ -118,6 +122,7 @@ interface PassThroughParams { caption?: string; scheduledAt?: string; rrule?: string; + editReminderId?: string; } function StepGroupsForm({ diff --git a/apps/web/src/components/reminder-wizard/step-review.tsx b/apps/web/src/components/reminder-wizard/step-review.tsx index 9f755ae..783a092 100644 --- a/apps/web/src/components/reminder-wizard/step-review.tsx +++ b/apps/web/src/components/reminder-wizard/step-review.tsx @@ -15,7 +15,7 @@ import { getSeededOperator } from "@/lib/operator"; import { getAccount, listGroupsForAccount } from "@/lib/queries"; import { ReviewSubmitClient } from "./review-submit-client"; import { DateTime } from "luxon"; -import { describeRecurrence, kindFromRrule } from "@/lib/recurrence"; +import { describeRecurrence, specFromRrule } from "@/lib/recurrence"; interface StepReviewParams { step?: string; @@ -26,25 +26,13 @@ interface StepReviewParams { caption?: string; scheduledAt?: string; rrule?: string; + editReminderId?: string; } interface StepReviewProps { params: StepReviewParams; } -const WEEKDAY_TO_ISO: Record = { - 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 { try { const dt = DateTime.fromISO(iso, { zone: timezone }); @@ -64,6 +52,7 @@ function editLink( caption?: string, scheduledAt?: string, rrule?: string, + editReminderId?: string, ): string { const sp = new URLSearchParams({ step: String(step), accountId }); if (groupIds) sp.set("groupIds", groupIds); @@ -72,11 +61,12 @@ function editLink( if (caption) sp.set("caption", caption); if (scheduledAt) sp.set("scheduledAt", scheduledAt); if (rrule) sp.set("rrule", rrule); + if (editReminderId) sp.set("editReminderId", editReminderId); return `/reminders/new?${sp.toString()}`; } 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)) { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -104,7 +94,7 @@ export async function StepReview({ params }: StepReviewProps) { const formattedDate = formatScheduledAt(scheduledAt, timezone); // 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 (
@@ -127,7 +117,7 @@ export async function StepReview({ params }: StepReviewProps) { } label="Account" - editHref={editLink(1, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule)} + editHref={editLink(1, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule, editReminderId)} > {account.label} {account.phoneNumber && ( @@ -139,7 +129,7 @@ export async function StepReview({ params }: StepReviewProps) { } label="Message" - editHref={editLink(2, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule)} + editHref={editLink(2, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule, editReminderId)} > {mediaId ? ( @@ -162,7 +152,7 @@ export async function StepReview({ params }: StepReviewProps) { } 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)} > {formattedDate} @@ -172,13 +162,12 @@ export async function StepReview({ params }: StepReviewProps) { } label="Repeats" - editHref={editLink(3, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule)} + editHref={editLink(3, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule, editReminderId)} > {describeRecurrence( - kindFromRrule(rrule), + specFromRrule(rrule), DateTime.fromISO(scheduledAt!, { zone: timezone }), - parseWeeklyDaysFromRrule(rrule), )} @@ -188,7 +177,7 @@ export async function StepReview({ params }: StepReviewProps) { } 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 ? (
@@ -217,6 +206,7 @@ export async function StepReview({ params }: StepReviewProps) { caption={caption} scheduledAt={scheduledAt} rrule={rrule} + editReminderId={editReminderId} timezone={timezone} />
diff --git a/apps/web/src/components/reminder-wizard/step-when.tsx b/apps/web/src/components/reminder-wizard/step-when.tsx index 6fdee9f..d5b4aac 100644 --- a/apps/web/src/components/reminder-wizard/step-when.tsx +++ b/apps/web/src/components/reminder-wizard/step-when.tsx @@ -1,11 +1,11 @@ import Link from "next/link"; import { redirect } from "next/navigation"; import { ArrowLeftIcon } from "lucide-react"; -import { DateTime } from "luxon"; import { Button } from "@/components/ui/button"; import { getSeededOperator } from "@/lib/operator"; import { WhenFormClient } from "./when-form-client"; -import { kindFromRrule } from "@/lib/recurrence"; +import { specFromRrule } from "@/lib/recurrence"; +import { defaultFirstFireIso } from "@/lib/date-picker"; interface StepWhenParams { step?: string; @@ -16,28 +16,15 @@ interface StepWhenParams { caption?: string; scheduledAt?: string; rrule?: string; + editReminderId?: string; } interface StepWhenProps { params: StepWhenParams; } -const WEEKDAY_TO_ISO: Record = { - 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) { - const { accountId, groupIds, text, mediaId, caption, scheduledAt, rrule } = params; + const { accountId, groupIds, text, mediaId, caption, scheduledAt, rrule, editReminderId } = params; if (!accountId || (!text && !mediaId)) { // 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 (caption) backParams.set("caption", caption); if (rrule) backParams.set("rrule", rrule); + if (editReminderId) backParams.set("editReminderId", editReminderId); const backHref = `/reminders/new?${backParams.toString()}`; return ( @@ -76,13 +64,9 @@ export async function StepWhen({ params }: StepWhenProps) { accountId={accountId} groupIds={groupIds ?? ""} timezone={timezone} - initialDefaultIso={ - scheduledAt ?? - DateTime.now().setZone(timezone).plus({ hours: 1 }).startOf("minute").toISO()! - } - initialKind={kindFromRrule(rrule)} - initialWeeklyDays={parseWeeklyDays(rrule)} - passThroughParams={{ text, mediaId, caption }} + initialDefaultIso={scheduledAt ?? defaultFirstFireIso(timezone)} + initialSpec={specFromRrule(rrule)} + passThroughParams={{ text, mediaId, caption, editReminderId }} />
); diff --git a/apps/web/src/components/reminder-wizard/when-form-client.tsx b/apps/web/src/components/reminder-wizard/when-form-client.tsx index 63f4387..1702479 100644 --- a/apps/web/src/components/reminder-wizard/when-form-client.tsx +++ b/apps/web/src/components/reminder-wizard/when-form-client.tsx @@ -11,13 +11,18 @@ import { cn } from "@/lib/utils"; import { WEEKDAY_LABELS, buildRrule, + describeRecurrence, type RecurrenceKind, + type RecurrenceSpec, + type EndKind, } from "@/lib/recurrence"; +import { splitDateTime, validateScheduledAt } from "@/lib/date-picker"; interface PassThroughParams { text?: string; mediaId?: string; caption?: string; + editReminderId?: string; } interface WhenFormClientProps { @@ -25,8 +30,7 @@ interface WhenFormClientProps { groupIds: string; timezone: string; initialDefaultIso: string; - initialKind?: RecurrenceKind; - initialWeeklyDays?: number[]; + initialSpec?: RecurrenceSpec; passThroughParams: PassThroughParams; } @@ -38,19 +42,19 @@ const KINDS: Array<{ value: RecurrenceKind; label: string }> = [ { value: "yearly", label: "Yearly" }, ]; -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") }; -} +const FREQ_UNIT: Record, string> = { + daily: "day", + weekly: "week", + monthly: "month", + yearly: "year", +}; export function WhenFormClient({ accountId, groupIds, timezone, initialDefaultIso, - initialKind = "none", - initialWeeklyDays = [], + initialSpec, passThroughParams, }: WhenFormClientProps) { const router = useRouter(); @@ -58,8 +62,19 @@ export function WhenFormClient({ const [date, setDate] = useState(initial.date); const [time, setTime] = useState(initial.time); - const [kind, setKind] = useState(initialKind); - const [weeklyDays, setWeeklyDays] = useState(initialWeeklyDays); + const [kind, setKind] = useState(initialSpec?.kind ?? "none"); + const [interval, setIntervalValue] = useState(initialSpec?.interval ?? 1); + const [weeklyDays, setWeeklyDays] = useState(initialSpec?.weeklyDays ?? []); + const [monthDay, setMonthDay] = useState( + initialSpec?.monthDay ?? "", + ); + const [endKind, setEndKind] = useState(initialSpec?.end.kind ?? "never"); + const [endCount, setEndCount] = useState( + initialSpec?.end.kind === "after" ? initialSpec.end.count : 10, + ); + const [endUntil, setEndUntil] = useState( + initialSpec?.end.kind === "on" ? initialSpec.end.until : "", + ); const [error, setError] = useState(null); 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() { - if (!date || !time) { - setError("Pick both a date and a time."); + const v = validateScheduledAt(date, time, timezone, Date.now()); + if (!v.ok) { + const map: Record = { + 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; } - const dt = DateTime.fromFormat(`${date} ${time}`, "yyyy-MM-dd HH:mm", { zone: timezone }); - if (!dt.isValid) { - setError("Invalid date or time."); + const dt = v.dt; + if (endKind === "on" && !endUntil) { + setError("Pick the end date for this recurrence."); return; } - if (dt.toMillis() <= Date.now()) { - setError("The first occurrence is in the past. Pick a future date and time."); + if (endKind === "on" && endUntil) { + 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; + } + } + if (endKind === "after" && (!Number.isFinite(endCount) || endCount < 1)) { + setError("Number of occurrences must be at least 1."); return; } - const rrule = buildRrule(kind, dt, weeklyDays); + const spec = buildSpec(dt); + const rrule = buildRrule(spec, dt); const scheduledAt = dt.toISO()!; const sp = new URLSearchParams({ step: "4", @@ -94,10 +143,21 @@ export function WhenFormClient({ if (passThroughParams.text) sp.set("text", passThroughParams.text); if (passThroughParams.mediaId) sp.set("mediaId", passThroughParams.mediaId); 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 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 (
{/* Date + time */} @@ -136,7 +196,7 @@ export function WhenFormClient({
- {/* Recurrence */} + {/* Frequency */}
- {/* Weekday picker — only for weekly */} - {kind === "weekly" && ( -
- -
- {WEEKDAY_LABELS.map(({ iso, short }) => { - const active = weeklyDays.includes(iso); - return ( - - ); - })} + {/* Recurrence detail — interval, weekdays, monthday, end */} + {kind !== "none" && ( +
+ {/* Interval */} +
+ + { + const n = Number(e.target.value); + setIntervalValue(Number.isFinite(n) && n >= 1 ? n : 1); + setError(null); + }} + className="h-8 w-20" + /> + + {FREQ_UNIT[kind]} + {interval === 1 ? "" : "s"} + +
+ + {/* Weekly days */} + {kind === "weekly" && ( +
+ +
+ {WEEKDAY_LABELS.map(({ iso, short }) => { + const active = weeklyDays.includes(iso); + return ( + + ); + })} +
+

+ Leave empty to use the start date's weekday only. +

+
+ )} + + {/* Monthly day-of-month */} + {kind === "monthly" && ( +
+ + { + 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" + /> +

+ Months without this day skip naturally (e.g. 31st). +

+
+ )} + + {/* End condition */} +
+ +
+ {(["never", "after", "on"] as const).map((v) => { + const active = endKind === v; + const label = v === "never" ? "Never" : v === "after" ? "After…" : "On date…"; + return ( + + ); + })} +
+ {endKind === "after" && ( +
+ { + const n = Number(e.target.value); + setEndCount(Number.isFinite(n) && n >= 1 ? n : 1); + setError(null); + }} + className="h-8 w-24" + /> + + occurrence{endCount === 1 ? "" : "s"} + +
+ )} + {endKind === "on" && ( +
+ { + setEndUntil(e.target.value); + setError(null); + }} + className="h-8 w-44" + /> +
+ )}
-

- Leave empty to use the start date's weekday only. -

)} + {/* Live preview */} + {previewSentence && ( +

+ {previewSentence} +

+ )} + {error && (
diff --git a/apps/web/src/lib/date-picker.test.ts b/apps/web/src/lib/date-picker.test.ts new file mode 100644 index 0000000..99ae345 --- /dev/null +++ b/apps/web/src/lib/date-picker.test.ts @@ -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"); + }); +}); diff --git a/apps/web/src/lib/date-picker.ts b/apps/web/src/lib/date-picker.ts new file mode 100644 index 0000000..ca5e245 --- /dev/null +++ b/apps/web/src/lib/date-picker.ts @@ -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 `` and + * `` 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()!; +} diff --git a/apps/web/src/lib/queries.ts b/apps/web/src/lib/queries.ts index bd8dea2..25555ca 100644 --- a/apps/web/src/lib/queries.ts +++ b/apps/web/src/lib/queries.ts @@ -93,7 +93,7 @@ export async function getGroup(operatorId: string, groupId: string) { export async function listReminders(operatorId: string) { const rows = await db.execute(sql` 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, (SELECT count(*) FROM reminder_targets rt WHERE rt.reminder_id = r.id) as group_count FROM reminders r @@ -107,6 +107,7 @@ export async function listReminders(operatorId: string) { name: r.name as string, scheduleKind: r.schedule_kind as string, scheduledAt: r.scheduled_at as Date | null, + rrule: (r.rrule as string | null) ?? null, timezone: r.timezone as string, status: r.status as string, createdAt: r.created_at as Date, diff --git a/apps/web/src/lib/recurrence.test.ts b/apps/web/src/lib/recurrence.test.ts index a327e11..13910b5 100644 --- a/apps/web/src/lib/recurrence.test.ts +++ b/apps/web/src/lib/recurrence.test.ts @@ -4,64 +4,207 @@ import { buildRrule, describeRecurrence, kindFromRrule, + specFromRrule, + type RecurrenceSpec, } from "./recurrence"; const FIRST = DateTime.fromISO("2026-05-13T09:00:00", { zone: "Asia/Kuala_Lumpur" }); +const baseSpec = (over: Partial = {}): RecurrenceSpec => ({ + kind: "none", + interval: 1, + weeklyDays: [], + end: { kind: "never" }, + ...over, +}); + describe("buildRrule", () => { it("returns null for one-off", () => { - expect(buildRrule("none", FIRST, [])).toBe(null); + expect(buildRrule(baseSpec({ kind: "none" }), FIRST)).toBe(null); }); - it("daily → FREQ=DAILY", () => { - expect(buildRrule("daily", FIRST, [])).toBe("FREQ=DAILY"); + it("daily simple", () => { + 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", () => { - // Pass days in mixed order — should be sorted by ISO weekday number - expect(buildRrule("weekly", FIRST, [3, 1, 5])).toBe("FREQ=WEEKLY;BYDAY=MO,WE,FR"); + it("daily with interval", () => { + expect(buildRrule(baseSpec({ kind: "daily", interval: 3 }), FIRST)).toBe( + "FREQ=DAILY;INTERVAL=3", + ); }); - it("weekly with no days falls back to first-fire weekday", () => { - // 2026-05-13 is a Wednesday in luxon ISO weekday → 3 - expect(buildRrule("weekly", FIRST, [])).toBe("FREQ=WEEKLY;BYDAY=WE"); + it("weekly with explicit days sorts to canonical order", () => { + expect( + 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", () => { - expect(buildRrule("monthly", FIRST, [])).toBe("FREQ=MONTHLY;BYMONTHDAY=13"); + it("weekly with no days falls back to first-fire weekday (Wed)", () => { + 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", () => { - 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", () => { - it("recognises every supported FREQ", () => { - expect(kindFromRrule(null)).toBe("none"); +describe("specFromRrule / kindFromRrule", () => { + it("returns the default spec for null/undefined", () => { + expect(specFromRrule(null)).toEqual({ + kind: "none", + interval: 1, + weeklyDays: [], + monthDay: undefined, + end: { kind: "never" }, + }); 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", () => { - expect(kindFromRrule("freq=daily")).toBe("daily"); + it("parses daily with interval", () => { + expect(specFromRrule("FREQ=DAILY;INTERVAL=3")).toEqual({ + kind: "daily", + interval: 3, + weeklyDays: [], + monthDay: undefined, + end: { kind: "never" }, + }); }); - it("returns 'none' for an unrecognised rule", () => { - expect(kindFromRrule("FREQ=HOURLY")).toBe("none"); + it("parses weekly with BYDAY", () => { + 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", () => { - it("renders human-readable summaries", () => { - expect(describeRecurrence("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"); - expect(describeRecurrence("monthly", FIRST, [])).toBe("Every month on day 13"); - expect(describeRecurrence("yearly", FIRST, [])).toBe("Every year on May 13"); + it("renders a one-off label", () => { + expect(describeRecurrence(baseSpec({ kind: "none" }), FIRST)).toBe("One-off"); + }); + + it("renders interval and unit pluralisation", () => { + expect(describeRecurrence(baseSpec({ kind: "daily" }), FIRST)).toBe("Every day"); + 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 '", () => { + expect( + describeRecurrence( + baseSpec({ kind: "daily", end: { kind: "on", until: "2026-06-01" } }), + FIRST, + ), + ).toBe("Every day, until 2026-06-01"); }); }); diff --git a/apps/web/src/lib/recurrence.ts b/apps/web/src/lib/recurrence.ts index dadf2a2..fae5f1c 100644 --- a/apps/web/src/lib/recurrence.ts +++ b/apps/web/src/lib/recurrence.ts @@ -1,8 +1,8 @@ import { DateTime } from "luxon"; 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; 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" }, ]; +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" - * (one-off reminders don't carry an RRULE). - * - * For weekly with no weekdays selected, falls back to the weekday of - * `firstFire` so the rule is always concrete. + * Build an RRULE string. Supports interval, weekday list, monthday, and the + * end condition (COUNT or UNTIL). Returns null for one-off reminders. */ -export function buildRrule( - kind: RecurrenceKind, - firstFire: DateTime, - weeklyDays: number[], -): string | null { - switch (kind) { - case "none": - return null; +export function buildRrule(spec: RecurrenceSpec, firstFire: DateTime): string | null { + if (spec.kind === "none") return null; + + const parts: string[] = []; + switch (spec.kind) { case "daily": - return "FREQ=DAILY"; + parts.push("FREQ=DAILY"); + break; case "weekly": { + parts.push("FREQ=WEEKLY"); const days = - weeklyDays.length > 0 - ? weeklyDays + spec.weeklyDays.length > 0 + ? spec.weeklyDays : [firstFire.weekday]; const codes = days .slice() .sort((a, b) => a - b) .map((d) => WEEKDAY_CODES[d - 1]) .filter(Boolean); - return `FREQ=WEEKLY;BYDAY=${codes.join(",")}`; + parts.push(`BYDAY=${codes.join(",")}`); + break; } case "monthly": - return `FREQ=MONTHLY;BYMONTHDAY=${firstFire.day}`; + parts.push("FREQ=MONTHLY"); + parts.push(`BYMONTHDAY=${spec.monthDay ?? firstFire.day}`); + break; 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; } -} -/** Human-readable summary, e.g. "Every Mon, Wed" or "Every month on the 14th". */ -export function describeRecurrence( - kind: RecurrenceKind, - firstFire: DateTime, - weeklyDays: number[], -): string { - switch (kind) { - case "none": - return "One-off"; - case "daily": - return "Every day"; - case "weekly": { - const days = weeklyDays.length > 0 ? weeklyDays : [firstFire.weekday]; - const labels = days - .slice() - .sort((a, b) => a - b) - .map((d) => WEEKDAY_LABELS[d - 1]?.short) - .filter(Boolean) - .join(", "); - return `Every ${labels}`; + 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'")}`); } - case "monthly": - return `Every month on day ${firstFire.day}`; - case "yearly": - return `Every year on ${firstFire.toFormat("MMM d")}`; } + + return parts.join(";"); } -/** Parse the kind back from an RRULE string (best-effort, for review display). */ +const FREQ_UNIT: Record = { + daily: "day", + weekly: "week", + monthly: "month", + yearly: "year", +}; + +/** + * Render the spec as a human sentence, e.g. + * "Every day" + * "Every 2 weeks on Mon, Wed, Fri" + * "Every month on day 14, 12 times" + * "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 + .slice() + .sort((a, b) => a - b) + .map((d) => WEEKDAY_LABELS[d - 1]?.short) + .filter(Boolean) + .join(", "); + 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")}`; + } + + let tail = ""; + if (spec.end.kind === "after" && spec.end.count > 0) { + 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 a stored RRULE back into a spec for resuming the wizard / editing. */ +export function specFromRrule(rrule: string | null | undefined): RecurrenceSpec { + if (!rrule) return { ...DEFAULT_RECURRENCE }; + + const tokens = rrule + .split(";") + .map((t) => t.trim()) + .filter(Boolean) + .reduce>((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 { - if (!rrule) return "none"; - const upper = rrule.toUpperCase(); - if (upper.includes("FREQ=DAILY")) return "daily"; - if (upper.includes("FREQ=WEEKLY")) return "weekly"; - if (upper.includes("FREQ=MONTHLY")) return "monthly"; - if (upper.includes("FREQ=YEARLY")) return "yearly"; - return "none"; + return specFromRrule(rrule).kind; }