feat: recurring reminders, fix QR pairing, account UX polish, tests

Reminders
- Add recurrence to wizard step 3 (None / Daily / Weekly+weekday picker /
  Monthly / Yearly). Build the RRULE client-side and thread it through
  the wizard URL state.
- Action stores rrule + scheduleKind="recurring" on insert.
- Bot reschedules the next occurrence after firing a recurring reminder
  using the existing rrule helpers in @cmbot/shared. One-off behavior
  unchanged.
- Add reminders.last_fired_at column to track last fire.

Pairing
- Move QR PNG out of the pg_notify payload (the 8000-byte limit was
  silently truncating it; QR never reached the web → "QR hang"). PNG
  now lives on whatsapp_accounts.last_qr_png; NOTIFY just signals
  {type: session.qr, accountId, ts}. Web fetches the bytes from a new
  read-only /api/qr/[accountId] route (allowed via middleware).
- handleStartPairing now stops any in-flight session before starting a
  fresh one — fixes Re-pair where session.start was a silent no-op and
  Baileys never re-emitted QR.
- Pair-live: countdown moved out from over the QR (it was overlapping
  the scan area); shown as a discrete progress bar above the QR.
- Add a "Save QR" download button.

Account detail page
- Pair / Unpair / Delete cards are themselves the trigger (form submit
  or DialogTrigger) — no inline buttons, whole card is clickable.
- Sync Groups Now card removed earlier; bot already auto-syncs.

Account list page
- Cards are the link target. A small floating Delete trigger (top-right
  trash icon) opens the destructive confirm dialog without blocking
  navigation on the rest of the card.

Tests
- recurrence.test.ts: 10 tests for buildRrule / kindFromRrule /
  describeRecurrence (incl. weekly day combos and BYDAY ordering).
- reminders.schema.test.ts: regression for the "Invalid datetime" bug —
  proves strict Zod .datetime() rejected luxon's offset ISO and the
  { offset: true } option accepts both forms.

Migration: 0004_next_prowler.sql
- whatsapp_accounts.last_qr_png (text)
- reminders.last_fired_at (timestamptz)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-05-10 01:01:31 +08:00
parent 86f2fe0124
commit 2b738383e4
23 changed files with 1717 additions and 235 deletions

View File

@ -3,7 +3,10 @@ import { db } from "../db.js";
import { logger } from "../logger.js"; import { logger } from "../logger.js";
export type WebEvent = export type WebEvent =
| { type: "session.qr"; accountId: string; qrPng: string /* base64 */ } // QR PNG bytes live in `whatsapp_accounts.last_qr_png` so this NOTIFY
// payload stays under Postgres' 8000-byte limit. Web fetches the PNG
// from /api/qr/[accountId] when it sees this event.
| { type: "session.qr"; accountId: string; ts: number }
| { type: "session.connected"; accountId: string; phoneNumber: string | null } | { type: "session.connected"; accountId: string; phoneNumber: string | null }
| { type: "session.disconnected"; accountId: string } | { type: "session.disconnected"; accountId: string }
| { type: "session.timeout"; accountId: string } | { type: "session.timeout"; accountId: string }

View File

@ -51,6 +51,19 @@ export async function handleStartPairing(accountId: string): Promise<void> {
return; return;
} }
// 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.)
if (sessionManager.hasSession(accountId)) {
await sessionManager.stop(accountId);
}
// Clear any stale QR lingering from a prior attempt.
lastQrPayload.delete(accountId);
await db
.update(whatsappAccounts)
.set({ lastQrPng: null })
.where(eq(whatsappAccounts.id, accountId));
const off = sessionManager.on(async (id, _state, event) => { const off = sessionManager.on(async (id, _state, event) => {
if (id !== accountId) return; if (id !== accountId) return;
try { try {
@ -58,10 +71,16 @@ export async function handleStartPairing(accountId: string): Promise<void> {
if (lastQrPayload.get(id) === event.payload) return; if (lastQrPayload.get(id) === event.payload) return;
lastQrPayload.set(id, event.payload); lastQrPayload.set(id, event.payload);
const png = await renderQrPng(event.payload); 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].
await db
.update(whatsappAccounts)
.set({ lastQrPng: png.toString("base64"), lastQrAt: new Date() })
.where(eq(whatsappAccounts.id, id));
await pgNotifyWeb({ await pgNotifyWeb({
type: "session.qr", type: "session.qr",
accountId: id, accountId: id,
qrPng: png.toString("base64"), ts: Date.now(),
}); });
} else if (event.type === "open") { } else if (event.type === "open") {
const t = pairTimeouts.get(id); const t = pairTimeouts.get(id);

View File

@ -4,10 +4,12 @@ import { db } from "../db.js";
import { logger } from "../logger.js"; import { logger } from "../logger.js";
import { sessionManager } from "../whatsapp/session-manager.js"; import { sessionManager } from "../whatsapp/session-manager.js";
import { sendTextToGroup, sendMediaToGroup } from "../whatsapp/sender.js"; import { sendTextToGroup, sendMediaToGroup } from "../whatsapp/sender.js";
import { absoluteMediaPath } from "@cmbot/shared"; import { absoluteMediaPath, nextOccurrence } from "@cmbot/shared";
import { env } from "../env.js"; import { env } from "../env.js";
import { writeAuditLog } from "../audit.js"; import { writeAuditLog } from "../audit.js";
import { getReminderWithDetails } from "../reminders/crud.js"; import { getReminderWithDetails } from "../reminders/crud.js";
import { getBoss } from "./pgboss-client.js";
import { scheduleReminderFire } from "./reminder-jobs.js";
export type FireReminderPayload = { reminderId: string }; export type FireReminderPayload = { reminderId: string };
@ -119,14 +121,31 @@ export async function fireReminder(payload: FireReminderPayload): Promise<void>
.set({ status }) .set({ status })
.where(eq(reminderRuns.id, runId)); .where(eq(reminderRuns.id, runId));
// One-off reminders are done after firing — flip them to 'ended' so the // One-off reminders end after firing. Recurring reminders compute the
// menu shows ⚪ instead of 🟢. Recurring reminders stay 'active' (more // next occurrence from the RRULE and re-arm the pg-boss job; only the
// occurrences pending; recurring is a future-plan feature). // last fire timestamp + updatedAt move forward.
if (reminder.scheduleKind === "one_off") { if (reminder.scheduleKind === "one_off") {
await db await db
.update(reminders) .update(reminders)
.set({ status: "ended", updatedAt: new Date() }) .set({ status: "ended", updatedAt: new Date() })
.where(eq(reminders.id, reminder.id)); .where(eq(reminders.id, reminder.id));
} else if (reminder.scheduleKind === "recurring" && reminder.rrule) {
const next = nextOccurrence(reminder.rrule, reminder.timezone, new Date());
await db
.update(reminders)
.set({ lastFiredAt: new Date(), updatedAt: new Date() })
.where(eq(reminders.id, reminder.id));
if (next) {
try {
await scheduleReminderFire(getBoss(), reminder.id, next);
logger.info({ reminderId: reminder.id, next }, "fire-reminder: re-armed for next occurrence");
} catch (err) {
logger.error({ err, reminderId: reminder.id }, "fire-reminder: failed to re-arm next occurrence");
}
} else {
logger.info({ reminderId: reminder.id }, "fire-reminder: no further occurrences, ending");
await db.update(reminders).set({ status: "ended" }).where(eq(reminders.id, reminder.id));
}
} }
await writeAuditLog(db, { await writeAuditLog(db, {

View File

@ -0,0 +1,30 @@
import { describe, it, expect } from "vitest";
import { z } from "zod";
import { DateTime } from "luxon";
/**
* Regression test for the "Invalid datetime" error.
*
* Earlier the action used `z.string().datetime()` (strict UTC `Z` only).
* Luxon's `dt.toISO()` produces an offset-suffixed form like
* `2026-05-10T09:00:00.000+08:00`, which the strict validator rejects.
* The fix uses `.datetime({ offset: true })`.
*
* If this test ever fails again it means the schema regressed and any
* Asia/Kuala_Lumpur reminder will be rejected at submit.
*/
describe("createReminderAction Zod schema (datetime validator)", () => {
const offsetIso = DateTime.fromISO("2026-05-10T09:00:00", { zone: "Asia/Kuala_Lumpur" }).toISO()!;
const utcIso = DateTime.fromISO("2026-05-10T09:00:00", { zone: "UTC" }).toISO()!;
it("strict .datetime() (no options) rejects offset-suffixed ISO — that was the bug", () => {
const strict = z.string().datetime();
expect(strict.safeParse(offsetIso).success).toBe(false);
});
it(".datetime({ offset: true }) accepts both offset and UTC ISO — that's the fix", () => {
const lenient = z.string().datetime({ offset: true });
expect(lenient.safeParse(offsetIso).success).toBe(true);
expect(lenient.safeParse(utcIso).success).toBe(true);
});
});

View File

@ -55,6 +55,7 @@ const createReminderSchema = z
// `.datetime({ offset: true })` accepts both UTC `Z` and zoned offsets // `.datetime({ offset: true })` accepts both UTC `Z` and zoned offsets
// like `+08:00` (luxon's `toISO()` produces the offset form). // like `+08:00` (luxon's `toISO()` produces the offset form).
scheduledAtIso: z.string().datetime({ offset: true }), scheduledAtIso: z.string().datetime({ offset: true }),
rrule: z.string().nullable().optional(),
timezone: z.string().default(DEFAULT_TIMEZONE), timezone: z.string().default(DEFAULT_TIMEZONE),
}) })
.refine((d) => Boolean(d.text?.trim()) || Boolean(d.mediaId), { .refine((d) => Boolean(d.text?.trim()) || Boolean(d.mediaId), {
@ -74,7 +75,7 @@ export async function createReminderAction(
if (!parsed.success) { if (!parsed.success) {
return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input" }; return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input" };
} }
const { accountId, groupIds, text, mediaId, caption, scheduledAtIso, timezone } = parsed.data; const { accountId, groupIds, text, mediaId, caption, scheduledAtIso, rrule, timezone } = parsed.data;
const op = await getSeededOperator(); const op = await getSeededOperator();
const account = await db.query.whatsappAccounts.findFirst({ const account = await db.query.whatsappAccounts.findFirst({
@ -104,8 +105,9 @@ export async function createReminderAction(
.values({ .values({
accountId, accountId,
name: (text ?? caption ?? "Reminder").slice(0, 50), name: (text ?? caption ?? "Reminder").slice(0, 50),
scheduleKind: "one_off", scheduleKind: rrule ? "recurring" : "one_off",
scheduledAt, scheduledAt,
rrule: rrule ?? null,
timezone, timezone,
status: "active", status: "active",
createdBy: op.id, createdBy: op.id,

View File

@ -10,6 +10,7 @@ import {
DatabaseIcon, DatabaseIcon,
PowerIcon, PowerIcon,
PowerOffIcon, PowerOffIcon,
ChevronRightIcon,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -17,7 +18,6 @@ import {
CardContent, CardContent,
CardHeader, CardHeader,
CardTitle, CardTitle,
CardDescription,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { import {
Dialog, Dialog,
@ -52,7 +52,6 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
return ( return (
<div className="px-4 py-6 sm:px-6 sm:py-8 max-w-2xl mx-auto space-y-6"> <div className="px-4 py-6 sm:px-6 sm:py-8 max-w-2xl 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={"/accounts" as any}> <Link href={"/accounts" as any}>
@ -61,7 +60,6 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
</Link> </Link>
</Button> </Button>
{/* Header */}
<div className="space-y-1"> <div className="space-y-1">
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-semibold tracking-tight">{account.label}</h1> <h1 className="text-2xl font-semibold tracking-tight">{account.label}</h1>
@ -75,11 +73,16 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
)} )}
</div> </div>
{/* Actions */}
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{/* Pair / Re-pair — visible when not currently connected */} {/* Pair / Re-pair — entire card is the submit button */}
{account.status !== "connected" && ( {account.status !== "connected" && (
<Card> <form action={pairAccountAction} className="contents">
<input type="hidden" name="accountId" value={account.id} />
<button
type="submit"
className="block w-full text-left rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<Card className="transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer">
<CardContent className="flex items-center justify-between gap-4 py-4"> <CardContent className="flex items-center justify-between gap-4 py-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex size-9 items-center justify-center rounded-lg bg-emerald-500/10"> <div className="flex size-9 items-center justify-center rounded-lg bg-emerald-500/10">
@ -94,24 +97,19 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
</p> </p>
</div> </div>
</div> </div>
<form action={pairAccountAction}> <ChevronRightIcon className="size-4 text-muted-foreground/60" />
<input type="hidden" name="accountId" value={account.id} />
<Button type="submit" size="sm">
<PowerIcon />
{account.status === "unpaired" ? "Pair Now" : "Re-pair"}
</Button>
</form>
</CardContent> </CardContent>
</Card> </Card>
</button>
</form>
)} )}
{/* Groups + Sync — visible when connected */}
{account.status === "connected" && ( {account.status === "connected" && (
<> <>
<Link <Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/accounts/${account.id}/groups` as any} href={`/accounts/${account.id}/groups` as any}
className="block focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl" className="block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
> >
<Card className="transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer"> <Card className="transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer">
<CardContent className="flex items-center justify-between gap-4 py-4"> <CardContent className="flex items-center justify-between gap-4 py-4">
@ -124,11 +122,19 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
<p className="text-xs text-muted-foreground">View synced WhatsApp groups</p> <p className="text-xs text-muted-foreground">View synced WhatsApp groups</p>
</div> </div>
</div> </div>
<ChevronRightIcon className="size-4 text-muted-foreground/60" />
</CardContent> </CardContent>
</Card> </Card>
</Link> </Link>
<Card> {/* Unpair — entire card opens the confirm dialog */}
<Dialog>
<DialogTrigger asChild>
<button
type="button"
className="block w-full text-left rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<Card className="transition-all hover:shadow-md hover:ring-amber-500/30 cursor-pointer">
<CardContent className="flex items-center justify-between gap-4 py-4"> <CardContent className="flex items-center justify-between gap-4 py-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex size-9 items-center justify-center rounded-lg bg-amber-500/10"> <div className="flex size-9 items-center justify-center rounded-lg bg-amber-500/10">
@ -141,12 +147,10 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
</p> </p>
</div> </div>
</div> </div>
<Dialog> <ChevronRightIcon className="size-4 text-muted-foreground/60" />
<DialogTrigger asChild> </CardContent>
<Button variant="outline" size="sm"> </Card>
<PowerOffIcon /> </button>
Unpair
</Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
@ -168,13 +172,17 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</CardContent>
</Card>
</> </>
)} )}
{/* Delete — always available */} {/* Delete — entire card opens the confirm dialog */}
<Card> <Dialog>
<DialogTrigger asChild>
<button
type="button"
className="block w-full text-left rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<Card className="transition-all hover:shadow-md hover:ring-destructive/30 cursor-pointer">
<CardContent className="flex items-center justify-between gap-4 py-4"> <CardContent className="flex items-center justify-between gap-4 py-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex size-9 items-center justify-center rounded-lg bg-destructive/10"> <div className="flex size-9 items-center justify-center rounded-lg bg-destructive/10">
@ -187,12 +195,10 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
</p> </p>
</div> </div>
</div> </div>
<Dialog> <ChevronRightIcon className="size-4 text-muted-foreground/60" />
<DialogTrigger asChild> </CardContent>
<Button variant="destructive" size="sm"> </Card>
<Trash2Icon /> </button>
Delete
</Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
@ -214,11 +220,8 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</CardContent>
</Card>
</div> </div>
{/* Detail grid */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-sm font-medium text-muted-foreground"> <CardTitle className="text-sm font-medium text-muted-foreground">

View File

@ -1,5 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { PlusIcon, SmartphoneIcon, CalendarIcon } from "lucide-react"; import { PlusIcon, SmartphoneIcon, CalendarIcon, Trash2Icon } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
@ -7,9 +7,19 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { AccountStatusBadge } from "@/components/account-status-badge"; import { AccountStatusBadge } from "@/components/account-status-badge";
import { getSeededOperator } from "@/lib/operator"; import { getSeededOperator } from "@/lib/operator";
import { listAccounts } from "@/lib/queries"; import { listAccounts } from "@/lib/queries";
import { deleteAccountAction } from "@/actions/accounts";
export default async function AccountsPage() { export default async function AccountsPage() {
const op = await getSeededOperator(); const op = await getSeededOperator();
@ -31,8 +41,8 @@ export default async function AccountsPage() {
{accounts.length > 0 ? ( {accounts.length > 0 ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{accounts.map((account) => ( {accounts.map((account) => (
<div key={account.id} className="relative">
<Link <Link
key={account.id}
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/accounts/${account.id}` as any} href={`/accounts/${account.id}` as any}
className="block focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl" className="block focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl"
@ -40,7 +50,9 @@ export default async function AccountsPage() {
<Card className="h-full transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer"> <Card className="h-full transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer">
<CardHeader> <CardHeader>
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<CardTitle className="text-base leading-snug">{account.label}</CardTitle> <CardTitle className="text-base leading-snug pr-8">
{account.label}
</CardTitle>
<AccountStatusBadge status={account.status} /> <AccountStatusBadge status={account.status} />
</div> </div>
</CardHeader> </CardHeader>
@ -70,6 +82,39 @@ export default async function AccountsPage() {
</CardContent> </CardContent>
</Card> </Card>
</Link> </Link>
{/* Floating delete trigger sits over the card without
blocking the link target on the rest of the surface. */}
<Dialog>
<DialogTrigger asChild>
<button
type="button"
aria-label={`Delete ${account.label}`}
className="absolute right-2 top-2 z-10 flex size-7 items-center justify-center rounded-full bg-background/80 text-muted-foreground hover:bg-destructive/10 hover:text-destructive backdrop-blur-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive"
>
<Trash2Icon className="size-3.5" />
</button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete this account?</DialogTitle>
<DialogDescription>
<strong>{account.label}</strong> and all its reminders, groups,
and history will be permanently removed. This cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter showCloseButton>
<form action={deleteAccountAction}>
<input type="hidden" name="accountId" value={account.id} />
<Button type="submit" variant="destructive" size="sm">
<Trash2Icon />
Yes, delete
</Button>
</form>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
))} ))}
</div> </div>
) : ( ) : (

View File

@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
interface RouteContext {
params: Promise<{ accountId: string }>;
}
export async function GET(_req: Request, ctx: RouteContext): Promise<Response> {
const { accountId } = await ctx.params;
const account = await db.query.whatsappAccounts.findFirst({
where: (a, { eq }) => eq(a.id, accountId),
columns: { lastQrPng: true },
});
if (!account?.lastQrPng) {
return new NextResponse("Not Found", { status: 404 });
}
const buf = Buffer.from(account.lastQrPng, "base64");
return new NextResponse(new Uint8Array(buf), {
headers: {
"Content-Type": "image/png",
"Cache-Control": "no-store",
},
});
}

View File

@ -3,14 +3,14 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { CheckCircle2Icon, XCircleIcon, ScanLineIcon } from "lucide-react"; import { CheckCircle2Icon, XCircleIcon, ScanLineIcon, DownloadIcon } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { useEvents } from "@/hooks/use-events"; import { useEvents } from "@/hooks/use-events";
type PairingState = type PairingState =
| { phase: "waiting" } | { phase: "waiting" }
| { phase: "qr"; qrPng: string } | { phase: "qr"; qrUrl: string }
| { phase: "connected"; phoneNumber: string } | { phase: "connected"; phoneNumber: string }
| { phase: "timeout" }; | { phase: "timeout" };
@ -19,42 +19,31 @@ interface PairLiveProps {
label: string; label: string;
} }
/** SVG countdown ring. radius=54 so circumference ≈ 339.3 */ function CountdownBar({ seconds, total }: { seconds: number; total: number }) {
function CountdownRing({ seconds, total }: { seconds: number; total: number }) { const pct = Math.max(0, Math.min(100, Math.round((seconds / total) * 100)));
const r = 54; const danger = seconds <= 10;
const circ = 2 * Math.PI * r;
const progress = seconds / total;
const dash = circ * progress;
return ( return (
<svg <div className="flex w-full max-w-64 flex-col gap-1">
className="absolute inset-0 -rotate-90" <div className="flex items-center justify-between text-xs">
width="128" <span className="text-muted-foreground">QR expires in</span>
height="128" <span
viewBox="0 0 128 128" className={`font-mono tabular-nums font-medium ${
aria-hidden danger ? "text-destructive" : "text-foreground"
}`}
> >
{/* Track */} {seconds}s
<circle </span>
cx="64" </div>
cy="64" <div className="h-1 w-full overflow-hidden rounded-full bg-muted">
r={r} <div
fill="none" className={`h-full transition-[width] duration-1000 ease-linear ${
strokeWidth="3" danger ? "bg-destructive" : "bg-foreground/70"
className="stroke-muted" }`}
style={{ width: `${pct}%` }}
aria-hidden
/> />
{/* Remaining */} </div>
<circle </div>
cx="64"
cy="64"
r={r}
fill="none"
strokeWidth="3"
strokeDasharray={`${dash} ${circ}`}
strokeLinecap="round"
className="stroke-foreground transition-[stroke-dasharray] duration-1000 ease-linear"
/>
</svg>
); );
} }
@ -84,7 +73,8 @@ export function PairLive({ accountId, label }: PairLiveProps) {
useEvents({ useEvents({
"session.qr": (data) => { "session.qr": (data) => {
if (data.accountId !== accountId) return; if (data.accountId !== accountId) return;
setPairingState({ phase: "qr", qrPng: data.qrPng }); // Bust the URL with the timestamp so the browser refetches each time.
setPairingState({ phase: "qr", qrUrl: `/api/qr/${accountId}?t=${data.ts}` });
startCountdown(); startCountdown();
}, },
"session.connected": (data) => { "session.connected": (data) => {
@ -136,39 +126,33 @@ export function PairLive({ accountId, label }: PairLiveProps) {
{pairingState.phase === "qr" && ( {pairingState.phase === "qr" && (
<div className="flex flex-col items-center gap-4"> <div className="flex flex-col items-center gap-4">
{/* QR + ring overlay */} {/* Countdown — separate from the QR so it doesn't obstruct scanning */}
<div className="relative"> <CountdownBar seconds={countdown} total={COUNTDOWN_TOTAL} />
{/* Countdown ring positioned around the QR */}
<div className="absolute -inset-5 flex items-center justify-center pointer-events-none">
<div className="relative size-32">
<CountdownRing seconds={countdown} total={COUNTDOWN_TOTAL} />
<div className="absolute inset-0 flex items-center justify-center">
<span
className={`font-mono text-xs tabular-nums font-medium transition-colors ${
countdown <= 10 ? "text-destructive" : "text-muted-foreground"
}`}
>
{countdown}s
</span>
</div>
</div>
</div>
{/* QR image */} {/* QR image */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src={`data:image/png;base64,${pairingState.qrPng}`} src={pairingState.qrUrl}
alt="WhatsApp QR code" alt="WhatsApp QR code"
width={256} width={256}
height={256} height={256}
className="rounded-lg ring-1 ring-foreground/10" className="rounded-lg ring-1 ring-foreground/10"
/> />
</div>
<div className="flex flex-col items-center gap-1 text-center"> <div className="flex flex-col items-center gap-2 text-center">
<p className="text-sm font-medium">Scan with WhatsApp Linked Devices</p> <p className="text-sm font-medium">Scan with WhatsApp Linked Devices</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Open WhatsApp tap Linked Devices Link a device Open WhatsApp tap Linked Devices Link a device
</p> </p>
<Button asChild variant="outline" size="sm" className="mt-1">
<a
href={pairingState.qrUrl}
download={`whatsapp-qr-${label.replace(/\s+/g, "-").toLowerCase() || accountId}.png`}
>
<DownloadIcon />
Save QR
</a>
</Button>
</div> </div>
</div> </div>
)} )}

View File

@ -19,6 +19,7 @@ interface PassThroughParams {
mediaId?: string; mediaId?: string;
caption?: string; caption?: string;
scheduledAt?: string; scheduledAt?: string;
rrule?: string;
} }
interface GroupsFormClientProps { interface GroupsFormClientProps {
@ -70,6 +71,7 @@ export function GroupsFormClient({
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.scheduledAt) sp.set("scheduledAt", passThroughParams.scheduledAt); if (passThroughParams.scheduledAt) sp.set("scheduledAt", passThroughParams.scheduledAt);
if (passThroughParams.rrule) sp.set("rrule", passThroughParams.rrule);
// 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

@ -14,6 +14,7 @@ interface ReviewSubmitClientProps {
mediaId?: string; mediaId?: string;
caption?: string; caption?: string;
scheduledAt: string; scheduledAt: string;
rrule?: string;
timezone: string; timezone: string;
} }
@ -24,6 +25,7 @@ export function ReviewSubmitClient({
mediaId, mediaId,
caption, caption,
scheduledAt, scheduledAt,
rrule,
timezone, timezone,
}: ReviewSubmitClientProps) { }: ReviewSubmitClientProps) {
const router = useRouter(); const router = useRouter();
@ -42,6 +44,7 @@ export function ReviewSubmitClient({
mediaId: mediaId ?? null, mediaId: mediaId ?? null,
caption: caption ?? null, caption: caption ?? null,
scheduledAtIso: scheduledAt, scheduledAtIso: scheduledAt,
rrule: rrule ?? null,
timezone, timezone,
}); });

View File

@ -14,6 +14,7 @@ interface StepGroupsParams {
mediaId?: string; mediaId?: string;
caption?: string; caption?: string;
scheduledAt?: string; scheduledAt?: string;
rrule?: string;
groupId?: string; groupId?: string;
} }
@ -22,7 +23,15 @@ interface StepGroupsProps {
} }
export async function StepGroups({ params }: StepGroupsProps) { export async function StepGroups({ params }: StepGroupsProps) {
const { accountId, groupIds: groupIdsParam, groupId: singleGroupId, scheduledAt, text, mediaId } = params; const {
accountId,
groupIds: groupIdsParam,
groupId: singleGroupId,
scheduledAt,
text,
mediaId,
rrule,
} = 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
@ -64,6 +73,7 @@ export async function StepGroups({ params }: StepGroupsProps) {
if (mediaId) backParams.set("mediaId", mediaId); if (mediaId) backParams.set("mediaId", mediaId);
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);
const backHref = `/reminders/new?${backParams.toString()}`; const backHref = `/reminders/new?${backParams.toString()}`;
return ( return (
@ -92,6 +102,7 @@ export async function StepGroups({ params }: StepGroupsProps) {
mediaId: params.mediaId, mediaId: params.mediaId,
caption: params.caption, caption: params.caption,
scheduledAt: params.scheduledAt, scheduledAt: params.scheduledAt,
rrule,
}} }}
/> />
</div> </div>
@ -106,6 +117,7 @@ interface PassThroughParams {
mediaId?: string; mediaId?: string;
caption?: string; caption?: string;
scheduledAt?: string; scheduledAt?: string;
rrule?: string;
} }
function StepGroupsForm({ function StepGroupsForm({

View File

@ -1,12 +1,21 @@
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { ArrowLeftIcon, PencilIcon, CalendarIcon, UsersIcon, FileTextIcon, SmartphoneIcon } from "lucide-react"; import {
ArrowLeftIcon,
PencilIcon,
CalendarIcon,
UsersIcon,
FileTextIcon,
SmartphoneIcon,
RepeatIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { getSeededOperator } from "@/lib/operator"; 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";
interface StepReviewParams { interface StepReviewParams {
step?: string; step?: string;
@ -16,12 +25,26 @@ interface StepReviewParams {
mediaId?: string; mediaId?: string;
caption?: string; caption?: string;
scheduledAt?: string; scheduledAt?: string;
rrule?: 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 });
@ -39,7 +62,8 @@ function editLink(
text?: string, text?: string,
mediaId?: string, mediaId?: string,
caption?: string, caption?: string,
scheduledAt?: string scheduledAt?: string,
rrule?: 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);
@ -47,11 +71,12 @@ function editLink(
if (mediaId) sp.set("mediaId", mediaId); if (mediaId) sp.set("mediaId", mediaId);
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);
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 } = params; const { accountId, groupIds, text, mediaId, caption, scheduledAt, rrule } = 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
@ -79,7 +104,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); const backHref = editLink(4, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule);
return ( return (
<div className="space-y-5"> <div className="space-y-5">
@ -102,7 +127,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)} editHref={editLink(1, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule)}
> >
<span className="text-sm font-medium">{account.label}</span> <span className="text-sm font-medium">{account.label}</span>
{account.phoneNumber && ( {account.phoneNumber && (
@ -114,7 +139,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)} editHref={editLink(2, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule)}
> >
{mediaId ? ( {mediaId ? (
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
@ -136,17 +161,34 @@ export async function StepReview({ params }: StepReviewProps) {
{/* When */} {/* When */}
<ReviewRow <ReviewRow
icon={<CalendarIcon className="size-4" />} icon={<CalendarIcon className="size-4" />}
label="When" label={rrule ? "First fire" : "When"}
editHref={editLink(3, accountId, groupIds, text, mediaId, caption, scheduledAt)} editHref={editLink(3, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule)}
> >
<span className="text-sm font-medium">{formattedDate}</span> <span className="text-sm font-medium">{formattedDate}</span>
</ReviewRow> </ReviewRow>
{/* Recurrence (only if set) */}
{rrule && (
<ReviewRow
icon={<RepeatIcon className="size-4" />}
label="Repeats"
editHref={editLink(3, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule)}
>
<span className="text-sm font-medium">
{describeRecurrence(
kindFromRrule(rrule),
DateTime.fromISO(scheduledAt!, { zone: timezone }),
parseWeeklyDaysFromRrule(rrule),
)}
</span>
</ReviewRow>
)}
{/* Groups */} {/* Groups */}
<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)} editHref={editLink(4, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule)}
> >
{selectedGroups.length > 0 ? ( {selectedGroups.length > 0 ? (
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
@ -174,6 +216,7 @@ export async function StepReview({ params }: StepReviewProps) {
mediaId={mediaId} mediaId={mediaId}
caption={caption} caption={caption}
scheduledAt={scheduledAt} scheduledAt={scheduledAt}
rrule={rrule}
timezone={timezone} timezone={timezone}
/> />
</div> </div>

View File

@ -5,6 +5,7 @@ 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";
interface StepWhenParams { interface StepWhenParams {
step?: string; step?: string;
@ -14,14 +15,29 @@ interface StepWhenParams {
mediaId?: string; mediaId?: string;
caption?: string; caption?: string;
scheduledAt?: string; scheduledAt?: string;
rrule?: 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 } = params; const { accountId, groupIds, text, mediaId, caption, scheduledAt, rrule } = 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
@ -36,6 +52,7 @@ export async function StepWhen({ params }: StepWhenProps) {
if (text) backParams.set("text", text); if (text) backParams.set("text", text);
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);
const backHref = `/reminders/new?${backParams.toString()}`; const backHref = `/reminders/new?${backParams.toString()}`;
return ( return (
@ -63,6 +80,8 @@ export async function StepWhen({ params }: StepWhenProps) {
scheduledAt ?? scheduledAt ??
DateTime.now().setZone(timezone).plus({ hours: 1 }).startOf("minute").toISO()! DateTime.now().setZone(timezone).plus({ hours: 1 }).startOf("minute").toISO()!
} }
initialKind={kindFromRrule(rrule)}
initialWeeklyDays={parseWeeklyDays(rrule)}
passThroughParams={{ text, mediaId, caption }} passThroughParams={{ text, mediaId, caption }}
/> />
</div> </div>

View File

@ -3,10 +3,16 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import { CalendarIcon, ClockIcon, AlertCircleIcon } from "lucide-react"; import { CalendarIcon, ClockIcon, AlertCircleIcon, RepeatIcon } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import {
WEEKDAY_LABELS,
buildRrule,
type RecurrenceKind,
} from "@/lib/recurrence";
interface PassThroughParams { interface PassThroughParams {
text?: string; text?: string;
@ -18,11 +24,20 @@ interface WhenFormClientProps {
accountId: string; accountId: string;
groupIds: string; groupIds: string;
timezone: string; timezone: string;
/** Pre-computed default ISO from the server — guarantees no hydration drift. */
initialDefaultIso: string; initialDefaultIso: string;
initialKind?: RecurrenceKind;
initialWeeklyDays?: number[];
passThroughParams: PassThroughParams; passThroughParams: PassThroughParams;
} }
const KINDS: Array<{ value: RecurrenceKind; label: string }> = [
{ value: "none", label: "One-off" },
{ value: "daily", label: "Daily" },
{ value: "weekly", label: "Weekly" },
{ value: "monthly", label: "Monthly" },
{ value: "yearly", label: "Yearly" },
];
function splitDateTime(iso: string, tz: string): { date: string; time: string } { function splitDateTime(iso: string, tz: string): { date: string; time: string } {
const dt = DateTime.fromISO(iso, { zone: tz }); const dt = DateTime.fromISO(iso, { zone: tz });
if (!dt.isValid) return { date: "", time: "" }; if (!dt.isValid) return { date: "", time: "" };
@ -34,6 +49,8 @@ export function WhenFormClient({
groupIds, groupIds,
timezone, timezone,
initialDefaultIso, initialDefaultIso,
initialKind = "none",
initialWeeklyDays = [],
passThroughParams, passThroughParams,
}: WhenFormClientProps) { }: WhenFormClientProps) {
const router = useRouter(); const router = useRouter();
@ -41,8 +58,16 @@ 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 [weeklyDays, setWeeklyDays] = useState<number[]>(initialWeeklyDays);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
function toggleWeekday(iso: number) {
setWeeklyDays((prev) =>
prev.includes(iso) ? prev.filter((d) => d !== iso) : [...prev, iso].sort((a, b) => a - b),
);
}
function handleContinue() { function handleContinue() {
if (!date || !time) { if (!date || !time) {
setError("Pick both a date and a time."); setError("Pick both a date and a time.");
@ -54,9 +79,10 @@ export function WhenFormClient({
return; return;
} }
if (dt.toMillis() <= Date.now()) { if (dt.toMillis() <= Date.now()) {
setError("The selected time is in the past. Choose a future time."); setError("The first occurrence is in the past. Pick a future date and time.");
return; return;
} }
const rrule = buildRrule(kind, dt, weeklyDays);
const scheduledAt = dt.toISO()!; const scheduledAt = dt.toISO()!;
const sp = new URLSearchParams({ const sp = new URLSearchParams({
step: "4", step: "4",
@ -64,6 +90,7 @@ export function WhenFormClient({
scheduledAt, scheduledAt,
}); });
if (groupIds) sp.set("groupIds", groupIds); if (groupIds) sp.set("groupIds", groupIds);
if (rrule) sp.set("rrule", rrule);
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);
@ -73,11 +100,12 @@ export function WhenFormClient({
return ( return (
<div className="space-y-5"> <div className="space-y-5">
{/* Date + time */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="scheduled-date" className="flex items-center gap-1.5"> <Label htmlFor="scheduled-date" className="flex items-center gap-1.5">
<CalendarIcon className="size-3.5" /> <CalendarIcon className="size-3.5" />
Date {kind === "none" ? "Date" : "Starts on"}
</Label> </Label>
<Input <Input
id="scheduled-date" id="scheduled-date"
@ -108,6 +136,66 @@ export function WhenFormClient({
</div> </div>
</div> </div>
{/* Recurrence */}
<div className="space-y-2">
<Label className="flex items-center gap-1.5">
<RepeatIcon className="size-3.5" />
Repeats
</Label>
<div className="flex flex-wrap gap-1.5">
{KINDS.map(({ value, label }) => {
const active = kind === value;
return (
<button
key={value}
type="button"
onClick={() => setKind(value)}
aria-pressed={active}
className={cn(
"inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium transition-colors",
active
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-muted/50 hover:border-primary/50 hover:bg-primary/5 hover:text-primary",
)}
>
{label}
</button>
);
})}
</div>
</div>
{/* Weekday picker — only for weekly */}
{kind === "weekly" && (
<div className="space-y-2">
<Label>Days of the week</Label>
<div className="flex flex-wrap gap-1.5">
{WEEKDAY_LABELS.map(({ iso, short }) => {
const active = weeklyDays.includes(iso);
return (
<button
key={iso}
type="button"
onClick={() => toggleWeekday(iso)}
aria-pressed={active}
className={cn(
"inline-flex h-8 min-w-12 items-center justify-center rounded-lg border px-2.5 text-xs font-medium transition-colors",
active
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-muted/50 hover:border-primary/50 hover:bg-primary/5 hover:text-primary",
)}
>
{short}
</button>
);
})}
</div>
<p className="text-xs text-muted-foreground">
Leave empty to use the start date's weekday only.
</p>
</div>
)}
{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

@ -5,7 +5,7 @@ import { useEffect } from "react";
export type WebEventMap = { export type WebEventMap = {
hello: { ts: number }; hello: { ts: number };
ping: { ts: number }; ping: { ts: number };
"session.qr": { accountId: string; qrPng: string }; "session.qr": { accountId: string; ts: number };
"session.connected": { accountId: string; phoneNumber: string | null }; "session.connected": { accountId: string; phoneNumber: string | null };
"session.disconnected": { accountId: string }; "session.disconnected": { accountId: string };
"session.timeout": { accountId: string }; "session.timeout": { accountId: string };

View File

@ -0,0 +1,67 @@
import { describe, it, expect } from "vitest";
import { DateTime } from "luxon";
import {
buildRrule,
describeRecurrence,
kindFromRrule,
} from "./recurrence";
const FIRST = DateTime.fromISO("2026-05-13T09:00:00", { zone: "Asia/Kuala_Lumpur" });
describe("buildRrule", () => {
it("returns null for one-off", () => {
expect(buildRrule("none", FIRST, [])).toBe(null);
});
it("daily → FREQ=DAILY", () => {
expect(buildRrule("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("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("monthly uses BYMONTHDAY of the first-fire date", () => {
expect(buildRrule("monthly", FIRST, [])).toBe("FREQ=MONTHLY;BYMONTHDAY=13");
});
it("yearly uses BYMONTH and BYMONTHDAY", () => {
expect(buildRrule("yearly", FIRST, [])).toBe("FREQ=YEARLY;BYMONTH=5;BYMONTHDAY=13");
});
});
describe("kindFromRrule", () => {
it("recognises every supported FREQ", () => {
expect(kindFromRrule(null)).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", () => {
expect(kindFromRrule("freq=daily")).toBe("daily");
});
it("returns 'none' for an unrecognised rule", () => {
expect(kindFromRrule("FREQ=HOURLY")).toBe("none");
});
});
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");
});
});

View File

@ -0,0 +1,91 @@
import { DateTime } from "luxon";
export type RecurrenceKind = "none" | "daily" | "weekly" | "monthly" | "yearly";
/** 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 }> = [
{ iso: 1, code: "MO", short: "Mon", long: "Monday" },
{ iso: 2, code: "TU", short: "Tue", long: "Tuesday" },
{ iso: 3, code: "WE", short: "Wed", long: "Wednesday" },
{ iso: 4, code: "TH", short: "Thu", long: "Thursday" },
{ iso: 5, code: "FR", short: "Fri", long: "Friday" },
{ iso: 6, code: "SA", short: "Sat", long: "Saturday" },
{ iso: 7, code: "SU", short: "Sun", long: "Sunday" },
];
/**
* 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.
*/
export function buildRrule(
kind: RecurrenceKind,
firstFire: DateTime,
weeklyDays: number[],
): string | null {
switch (kind) {
case "none":
return null;
case "daily":
return "FREQ=DAILY";
case "weekly": {
const days =
weeklyDays.length > 0
? 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(",")}`;
}
case "monthly":
return `FREQ=MONTHLY;BYMONTHDAY=${firstFire.day}`;
case "yearly":
return `FREQ=YEARLY;BYMONTH=${firstFire.month};BYMONTHDAY=${firstFire.day}`;
}
}
/** 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}`;
}
case "monthly":
return `Every month on day ${firstFire.day}`;
case "yearly":
return `Every year on ${firstFire.toFormat("MMM d")}`;
}
}
/** Parse the kind back from an RRULE string (best-effort, for review display). */
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";
}

View File

@ -3,9 +3,13 @@ import { NextRequest, NextResponse } from "next/server";
export function middleware(req: NextRequest) { export function middleware(req: NextRequest) {
const path = req.nextUrl.pathname; const path = req.nextUrl.pathname;
// Block all /api/* except the read-only SSE and health endpoints. // Block all /api/* except a small set of read-only endpoints.
// Mutations happen via Server Actions which post to page URLs, not /api/*. // Mutations happen via Server Actions which post to page URLs, not /api/*.
if (path.startsWith("/api/") && path !== "/api/events" && path !== "/api/health") { const allowed =
path === "/api/events" ||
path === "/api/health" ||
path.startsWith("/api/qr/");
if (path.startsWith("/api/") && !allowed) {
return new NextResponse("Not Found", { status: 404 }); return new NextResponse("Not Found", { status: 404 });
} }

View File

@ -0,0 +1,2 @@
ALTER TABLE "reminders" ADD COLUMN "last_fired_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "whatsapp_accounts" ADD COLUMN "last_qr_png" text;

File diff suppressed because it is too large Load Diff

View File

@ -29,6 +29,13 @@
"when": 1778343712901, "when": 1778343712901,
"tag": "0003_messy_bruce_banner", "tag": "0003_messy_bruce_banner",
"breakpoints": true "breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1778345543406,
"tag": "0004_next_prowler",
"breakpoints": true
} }
] ]
} }

View File

@ -37,6 +37,7 @@ export const whatsappAccounts = pgTable(
status: text("status").notNull().default("pending"), status: text("status").notNull().default("pending"),
lastConnectedAt: timestamp("last_connected_at", { withTimezone: true }), lastConnectedAt: timestamp("last_connected_at", { withTimezone: true }),
lastQrAt: timestamp("last_qr_at", { withTimezone: true }), lastQrAt: timestamp("last_qr_at", { withTimezone: true }),
lastQrPng: text("last_qr_png"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}, },
(t) => ({ (t) => ({
@ -85,6 +86,7 @@ export const reminders = pgTable("reminders", {
createdBy: uuid("created_by").notNull().references(() => operators.id), createdBy: uuid("created_by").notNull().references(() => operators.id),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
lastFiredAt: timestamp("last_fired_at", { withTimezone: true }),
}); });
export const reminderTargets = pgTable( export const reminderTargets = pgTable(