feat: per-section reminder edit, activity tab, more tests

Per-section reminder editing
- Replace the wizard-redirect edit shell with four focused single-form
  pages: /reminders/[id]/edit/{account,message,when,groups}.
- Each click on a section card on the detail page goes straight to the
  matching focused editor — no stepper, no other sections, no
  wizard chrome. Save returns to the detail page.
- New form components live under components/reminder-edit/:
  EditMessageForm, EditWhenForm (full recurrence builder reused),
  EditGroupsForm, EditAccountForm. All submit via updateReminderAction
  with the existing values for untouched fields. Switching account
  clears group targets (groups are scoped per account; the form warns
  and the user re-picks groups afterwards).

Activity tab
- New "Activity" item in the bottom nav + sidebar (between Reminders
  and Settings).
- /activity page: full run history (last 200), filter tabs (All /
  Success / Partial / Failed / Skipped), clickable rows that open the
  underlying reminder, and a Clear history dialog. Mirrors the
  dashboard's Recent Activity widget but with deeper data and its own
  empty-state messaging.

Tests (+20 — 80 passing total)
- qr-dedupe.test.ts: 14 tests covering the makeQrDedupe factory (per-
  account, fresh QRs always emit, reset/scope) and countdownRender
  (the QR-expired timer logic — danger threshold, expired flag,
  clamping). The dedupe + countdown logic is now used by pair-handler
  and pair-live.
- reminder-edit/edit-message-form.test.tsx: 6 tests verifying the form
  pre-fills, hides/shows the caption based on attachment, renders the
  Save (not "Schedule reminder") action, and the action receives the
  expected payload shape for both text-only and media-attached paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-05-10 01:35:17 +08:00
parent ba9e50fec0
commit 6cb387bf59
18 changed files with 1691 additions and 90 deletions

View File

@ -0,0 +1,284 @@
import Link from "next/link";
import {
ActivityIcon,
CheckCircle2Icon,
AlertTriangleIcon,
XCircleIcon,
MinusCircleIcon,
Trash2Icon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { getSeededOperator } from "@/lib/operator";
import { listActivityRuns } from "@/lib/queries";
import { clearHistoryAction } from "@/actions/history";
function relativeTime(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
const diffSec = Math.floor((Date.now() - d.getTime()) / 1000);
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
if (diffSec < 60) return rtf.format(-diffSec, "second");
if (diffSec < 3600) return rtf.format(-Math.floor(diffSec / 60), "minute");
if (diffSec < 86400) return rtf.format(-Math.floor(diffSec / 3600), "hour");
return rtf.format(-Math.floor(diffSec / 86400), "day");
}
const RUN_STATUS_CONFIG: Record<
string,
{ label: string; className: string; icon: React.ElementType }
> = {
success: {
label: "Success",
className:
"bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent",
icon: CheckCircle2Icon,
},
partial: {
label: "Partial",
className:
"bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400 border-transparent",
icon: AlertTriangleIcon,
},
failed: {
label: "Failed",
className:
"bg-red-500/15 text-red-600 dark:bg-red-500/20 dark:text-red-400 border-transparent",
icon: XCircleIcon,
},
skipped: {
label: "Skipped",
className:
"bg-slate-200/60 text-slate-500 dark:bg-slate-700/40 dark:text-slate-400 border-transparent",
icon: MinusCircleIcon,
},
};
function RunStatusBadge({ status }: { status: string }) {
const cfg = RUN_STATUS_CONFIG[status] ?? {
label: status,
className: "bg-secondary text-secondary-foreground border-transparent",
icon: ActivityIcon,
};
const Icon = cfg.icon;
return (
<Badge variant="secondary" className={cfg.className}>
<Icon className="size-3 mr-0.5" />
{cfg.label}
</Badge>
);
}
type FilterValue = "all" | "success" | "partial" | "failed" | "skipped";
const FILTER_TABS: { value: FilterValue; label: string }[] = [
{ value: "all", label: "All" },
{ value: "success", label: "Success" },
{ value: "partial", label: "Partial" },
{ value: "failed", label: "Failed" },
{ value: "skipped", label: "Skipped" },
];
interface PageProps {
searchParams: Promise<{ filter?: string }>;
}
export default async function ActivityPage({ searchParams }: PageProps) {
const sp = await searchParams;
const filter: FilterValue =
sp.filter === "success" ||
sp.filter === "partial" ||
sp.filter === "failed" ||
sp.filter === "skipped"
? sp.filter
: "all";
const op = await getSeededOperator();
const runs = await listActivityRuns(op.id);
const filtered = filter === "all" ? runs : runs.filter((r) => r.status === filter);
const hasAny = runs.length > 0;
return (
<div className="px-4 py-6 sm:px-6 sm:py-8 max-w-5xl mx-auto space-y-6">
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-semibold tracking-tight">Activity</h1>
{hasAny && (
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="text-muted-foreground hover:text-destructive">
<Trash2Icon />
Clear history
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Clear all run history?</DialogTitle>
<DialogDescription>
This permanently removes every reminder run record, including
runs from reminders that have already been deleted. Reminders
themselves are not affected.
</DialogDescription>
</DialogHeader>
<DialogFooter showCloseButton>
<form action={clearHistoryAction}>
<Button type="submit" variant="destructive" size="sm">
<Trash2Icon />
Yes, clear history
</Button>
</form>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</div>
<Tabs value={filter}>
<TabsList>
{FILTER_TABS.map(({ value, label }) => (
<TabsTrigger key={value} value={value} asChild>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={(value === "all" ? "/activity" : `/activity?filter=${value}`) as any}>
{label}
</Link>
</TabsTrigger>
))}
</TabsList>
</Tabs>
{filtered.length > 0 ? (
<>
{/* Mobile: cards */}
<div className="flex flex-col gap-2 sm:hidden">
{filtered.map((run) => {
const body = (
<Card
size="sm"
className={
run.reminderId && !run.isDeleted
? "transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer"
: undefined
}
>
<CardContent className="flex items-center justify-between gap-3 py-3">
<div className="min-w-0">
<p className="text-sm font-medium truncate">
{run.reminderName}
{run.isDeleted && (
<span className="ml-1.5 text-xs font-normal text-muted-foreground italic">
(deleted)
</span>
)}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{relativeTime(run.firedAt)}
</p>
</div>
<RunStatusBadge status={run.status} />
</CardContent>
</Card>
);
return run.reminderId && !run.isDeleted ? (
<Link
key={run.id}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/reminders/${run.reminderId}` as any}
className="block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{body}
</Link>
) : (
<div key={run.id}>{body}</div>
);
})}
</div>
{/* Desktop: table */}
<div className="hidden sm:block">
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Reminder</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Fired</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((run) => {
const clickable = run.reminderId && !run.isDeleted;
return (
<TableRow
key={run.id}
className={clickable ? "cursor-pointer hover:bg-muted/50" : undefined}
>
<TableCell className="font-medium">
{clickable ? (
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/reminders/${run.reminderId}` as any}
className="block focus-visible:outline-none focus-visible:underline"
>
{run.reminderName}
</Link>
) : (
<span className="text-muted-foreground italic">
{run.reminderName}
{run.isDeleted && " (deleted)"}
</span>
)}
</TableCell>
<TableCell>
<RunStatusBadge status={run.status} />
</TableCell>
<TableCell className="text-right text-muted-foreground text-xs">
{relativeTime(run.firedAt)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
</>
) : (
<Card>
<CardContent className="flex flex-col items-center gap-3 py-12 text-center">
<ActivityIcon className="size-10 text-muted-foreground/40" />
<div className="space-y-1">
<p className="text-sm font-medium">
{filter === "all"
? "No activity yet."
: `No ${filter} runs yet.`}
</p>
<p className="text-xs text-muted-foreground">
{hasAny
? "Runs in other states aren't shown by this filter."
: "Reminder fire events will appear here."}
</p>
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

@ -0,0 +1,45 @@
import { notFound } from "next/navigation";
import { getSeededOperator } from "@/lib/operator";
import { getReminderWithRuns, listAccounts } from "@/lib/queries";
import { EditShell } from "@/components/reminder-edit/edit-shell";
import { EditAccountForm } from "@/components/reminder-edit/edit-account-form";
interface Props {
params: Promise<{ id: string }>;
}
export default async function EditAccountPage({ params }: Props) {
const { id } = await params;
const op = await getSeededOperator();
const data = await getReminderWithRuns(op.id, id);
if (!data) notFound();
const { reminder, messages } = data;
const allAccounts = await listAccounts(op.id);
const first = messages[0];
return (
<EditShell
reminderId={reminder.id}
title="Edit account"
description="Switch which WhatsApp account sends this reminder. Group targets reset because groups are scoped per account."
>
<EditAccountForm
reminderId={reminder.id}
scheduledAtIso={(reminder.scheduledAt ?? new Date()).toISOString()}
rrule={reminder.rrule}
text={first && !first.mediaId ? first.textContent ?? null : null}
mediaId={first?.mediaId ?? null}
caption={first?.mediaId ? first.textContent ?? null : null}
timezone={reminder.timezone}
accounts={allAccounts.map((a) => ({
id: a.id,
label: a.label,
status: a.status,
phoneNumber: a.phoneNumber,
}))}
initialAccountId={reminder.accountId}
/>
</EditShell>
);
}

View File

@ -0,0 +1,42 @@
import { notFound } from "next/navigation";
import { getSeededOperator } from "@/lib/operator";
import { getReminderWithRuns, listGroupsForAccount } from "@/lib/queries";
import { EditShell } from "@/components/reminder-edit/edit-shell";
import { EditGroupsForm } from "@/components/reminder-edit/edit-groups-form";
interface Props {
params: Promise<{ id: string }>;
}
export default async function EditGroupsPage({ 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 groupsResult = await listGroupsForAccount(op.id, reminder.accountId);
const groups = groupsResult?.groups ?? [];
const first = messages[0];
return (
<EditShell
reminderId={reminder.id}
title="Edit groups"
description="Pick which WhatsApp groups receive this reminder. Leave empty to save without targets."
>
<EditGroupsForm
reminderId={reminder.id}
accountId={reminder.accountId}
scheduledAtIso={(reminder.scheduledAt ?? new Date()).toISOString()}
rrule={reminder.rrule}
text={first && !first.mediaId ? first.textContent ?? null : null}
mediaId={first?.mediaId ?? null}
caption={first?.mediaId ? first.textContent ?? null : null}
timezone={reminder.timezone}
groups={groups}
initialSelected={targets.map((t) => t.groupId)}
/>
</EditShell>
);
}

View File

@ -0,0 +1,41 @@
import { notFound } from "next/navigation";
import { getSeededOperator } from "@/lib/operator";
import { getReminderWithRuns } from "@/lib/queries";
import { EditShell } from "@/components/reminder-edit/edit-shell";
import { EditMessageForm } from "@/components/reminder-edit/edit-message-form";
interface Props {
params: Promise<{ id: string }>;
}
export default async function EditMessagePage({ 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 first = messages[0];
const text = first && !first.mediaId ? first.textContent ?? "" : "";
const caption = first && first.mediaId ? first.textContent ?? "" : "";
return (
<EditShell
reminderId={reminder.id}
title="Edit message"
description="Change the reminder text. The schedule, account, and groups stay as they are."
>
<EditMessageForm
reminderId={reminder.id}
accountId={reminder.accountId}
groupIds={targets.map((t) => t.groupId)}
scheduledAtIso={(reminder.scheduledAt ?? new Date()).toISOString()}
rrule={reminder.rrule}
timezone={reminder.timezone}
initialText={text}
initialMediaId={first?.mediaId ?? null}
initialCaption={caption}
/>
</EditShell>
);
}

View File

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

View File

@ -0,0 +1,40 @@
import { notFound } from "next/navigation";
import { getSeededOperator } from "@/lib/operator";
import { getReminderWithRuns } from "@/lib/queries";
import { specFromRrule } from "@/lib/recurrence";
import { EditShell } from "@/components/reminder-edit/edit-shell";
import { EditWhenForm } from "@/components/reminder-edit/edit-when-form";
interface Props {
params: Promise<{ id: string }>;
}
export default async function EditWhenPage({ 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 first = messages[0];
return (
<EditShell
reminderId={reminder.id}
title="Edit schedule"
description="Change when this reminder fires and how often it repeats."
>
<EditWhenForm
reminderId={reminder.id}
accountId={reminder.accountId}
groupIds={targets.map((t) => t.groupId)}
text={first && !first.mediaId ? first.textContent ?? null : null}
mediaId={first?.mediaId ?? null}
caption={first?.mediaId ? first.textContent ?? null : null}
initialIso={(reminder.scheduledAt ?? new Date()).toISOString()}
initialSpec={specFromRrule(reminder.rrule)}
timezone={reminder.timezone}
/>
</EditShell>
);
}

View File

@ -85,32 +85,11 @@ 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()}`;
}
// Per-section edit pages — each opens a focused single-form editor for
// just that part of the reminder, no multi-step flow.
type Section = "account" | "message" | "when" | "groups";
const editHref = (section: Section): string =>
`/reminders/${reminder.id}/edit/${section}`;
const cardClasses =
"transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer";
@ -143,7 +122,7 @@ export default async function ReminderDetailPage({ params }: Props) {
{/* Account — click to edit step 1 */}
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={editStepHref(1) as any} className={linkWrapperClasses} aria-label="Edit account">
<Link href={editHref("account") as any} className={linkWrapperClasses} aria-label="Edit account">
<Card className={cardClasses}>
<CardContent className="flex items-start gap-3 py-4 px-4">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">
@ -165,7 +144,7 @@ export default async function ReminderDetailPage({ params }: Props) {
{/* Message — click to edit step 2 */}
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={editStepHref(2) as any} className={linkWrapperClasses} aria-label="Edit message">
<Link href={editHref("message") as any} className={linkWrapperClasses} aria-label="Edit message">
<Card className={cardClasses}>
<CardContent className="flex items-start gap-3 py-4 px-4">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">
@ -201,7 +180,7 @@ export default async function ReminderDetailPage({ params }: Props) {
{/* When / Recurrence — click to edit step 3 */}
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={editStepHref(3) as any} className={linkWrapperClasses} aria-label="Edit schedule">
<Link href={editHref("when") as any} className={linkWrapperClasses} aria-label="Edit schedule">
<Card className={cardClasses}>
<CardContent className="flex items-start gap-3 py-4 px-4">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">
@ -231,7 +210,7 @@ export default async function ReminderDetailPage({ params }: Props) {
{/* Groups — click to edit step 4 */}
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={editStepHref(4) as any} className={linkWrapperClasses} aria-label="Edit groups">
<Link href={editHref("groups") as any} className={linkWrapperClasses} aria-label="Edit groups">
<Card className={cardClasses}>
<CardContent className="flex items-start gap-3 py-4 px-4">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">

View File

@ -1,4 +1,4 @@
import { Home, Smartphone, Calendar, Settings } from "lucide-react";
import { Home, Smartphone, Calendar, Activity, Settings } from "lucide-react";
import type { LucideIcon } from "lucide-react";
export interface NavItem {
@ -12,5 +12,6 @@ export const NAV_ITEMS: NavItem[] = [
{ key: "dashboard", href: "/", label: "Dashboard", icon: Home },
{ key: "accounts", href: "/accounts", label: "Accounts", icon: Smartphone },
{ key: "reminders", href: "/reminders", label: "Reminders", icon: Calendar },
{ key: "activity", href: "/activity", label: "Activity", icon: Activity },
{ key: "settings", href: "/settings", label: "Settings", icon: Settings },
];

View File

@ -7,6 +7,7 @@ import { CheckCircle2Icon, XCircleIcon, ScanLineIcon, DownloadIcon } from "lucid
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { useEvents } from "@/hooks/use-events";
import { countdownRender } from "@/lib/qr-dedupe";
type PairingState =
| { phase: "waiting" }
@ -20,19 +21,22 @@ interface PairLiveProps {
}
function CountdownBar({ seconds, total }: { seconds: number; total: number }) {
const pct = Math.max(0, Math.min(100, Math.round((seconds / total) * 100)));
const danger = seconds <= 10;
const { pct, danger, expired } = countdownRender(seconds, total);
return (
<div className="flex w-full max-w-64 flex-col gap-1">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">QR expires in</span>
<span
className={`font-mono tabular-nums font-medium ${
danger ? "text-destructive" : "text-foreground"
}`}
>
{seconds}s
<span className="text-muted-foreground">
{expired ? "QR expired — waiting for refresh" : "QR expires in"}
</span>
{!expired && (
<span
className={`font-mono tabular-nums font-medium ${
danger ? "text-destructive" : "text-foreground"
}`}
>
{seconds}s
</span>
)}
</div>
<div className="h-1 w-full overflow-hidden rounded-full bg-muted">
<div

View File

@ -0,0 +1,176 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import {
AlertCircleIcon,
Loader2Icon,
SaveIcon,
SmartphoneIcon,
WifiIcon,
WifiOffIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { updateReminderAction } from "@/actions/reminders";
interface AccountOption {
id: string;
label: string;
status: string;
phoneNumber: string | null;
}
interface EditAccountFormProps {
reminderId: string;
scheduledAtIso: string;
rrule: string | null;
text: string | null;
mediaId: string | null;
caption: string | null;
timezone: string;
accounts: AccountOption[];
initialAccountId: string;
}
export function EditAccountForm({
reminderId,
scheduledAtIso,
rrule,
text,
mediaId,
caption,
timezone,
accounts,
initialAccountId,
}: EditAccountFormProps) {
const router = useRouter();
const [selected, setSelected] = useState(initialAccountId);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const accountChanged = selected !== initialAccountId;
async function handleSave() {
setSubmitting(true);
setError(null);
try {
const r = await updateReminderAction({
reminderId,
accountId: selected,
// Account scope changes invalidate group selection — drop targets
// when switching accounts so the action doesn't fail validating a
// mixed-account groupIds set. The user re-picks groups afterwards.
groupIds: accountChanged ? [] : [],
text,
mediaId,
caption,
scheduledAtIso,
rrule,
timezone,
});
if (r.ok) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
router.push(`/reminders/${reminderId}` as any);
} else {
setError(r.error);
setSubmitting(false);
}
} catch (e) {
setError(e instanceof Error ? e.message : "Unexpected error");
setSubmitting(false);
}
}
return (
<div className="space-y-4">
{accounts.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
No accounts paired yet. Pair an account before you can change this.
</p>
) : (
<div className="grid grid-cols-1 gap-2">
{accounts.map((account) => {
const active = account.id === selected;
const connected = account.status === "connected";
return (
<button
key={account.id}
type="button"
onClick={() => setSelected(account.id)}
aria-pressed={active}
className={cn(
"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={cn(
"transition-all",
active
? "ring-2 ring-primary/60"
: "hover:shadow-md hover:ring-primary/30 cursor-pointer",
!connected && "opacity-70",
)}
>
<CardContent className="flex items-center gap-3 py-3 px-4">
<div
className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-lg",
connected
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
: "bg-muted text-muted-foreground",
)}
>
<SmartphoneIcon className="size-4" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium leading-snug truncate">
{account.label}
</p>
<p className="text-xs text-muted-foreground truncate">
{account.phoneNumber ?? "Not paired"}
</p>
</div>
{connected ? (
<WifiIcon className="size-4 text-emerald-500 shrink-0" />
) : (
<WifiOffIcon className="size-4 text-muted-foreground/50 shrink-0" />
)}
</CardContent>
</Card>
</button>
);
})}
</div>
)}
{accountChanged && (
<div className="flex items-center gap-1.5 rounded-lg bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-400">
<AlertCircleIcon className="size-3.5 shrink-0" />
Group targets will be cleared because groups are scoped per account.
Re-pick them on the Groups section after saving.
</div>
)}
{error && (
<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" />
{error}
</div>
)}
<div className="flex justify-end">
<Button
type="button"
onClick={handleSave}
disabled={submitting || accounts.length === 0}
className="gap-2"
>
{submitting ? <Loader2Icon className="size-4 animate-spin" /> : <SaveIcon className="size-4" />}
{submitting ? "Saving…" : "Save"}
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,196 @@
"use client";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import {
AlertCircleIcon,
Loader2Icon,
SaveIcon,
SearchIcon,
UsersIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { updateReminderAction } from "@/actions/reminders";
interface Group {
id: string;
name: string;
participantCount: number;
isArchived: boolean;
}
interface EditGroupsFormProps {
reminderId: string;
accountId: string;
scheduledAtIso: string;
rrule: string | null;
text: string | null;
mediaId: string | null;
caption: string | null;
timezone: string;
groups: Group[];
initialSelected: string[];
}
export function EditGroupsForm({
reminderId,
accountId,
scheduledAtIso,
rrule,
text,
mediaId,
caption,
timezone,
groups,
initialSelected,
}: EditGroupsFormProps) {
const router = useRouter();
const [selected, setSelected] = useState<Set<string>>(() => new Set(initialSelected));
const [search, setSearch] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return groups;
return groups.filter((g) => g.name.toLowerCase().includes(q));
}, [groups, search]);
function toggle(id: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
setError(null);
}
async function handleSave() {
setSubmitting(true);
setError(null);
try {
const r = await updateReminderAction({
reminderId,
accountId,
groupIds: Array.from(selected),
text,
mediaId,
caption,
scheduledAtIso,
rrule,
timezone,
});
if (r.ok) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
router.push(`/reminders/${reminderId}` as any);
} else {
setError(r.error);
setSubmitting(false);
}
} catch (e) {
setError(e instanceof Error ? e.message : "Unexpected error");
setSubmitting(false);
}
}
return (
<div className="space-y-3">
<div className="relative">
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
<Input
type="search"
placeholder="Search groups…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8"
aria-label="Search groups"
/>
</div>
{selected.size > 0 && (
<p className="text-xs text-muted-foreground">
{selected.size} group{selected.size !== 1 ? "s" : ""} selected
</p>
)}
{filtered.length === 0 ? (
<div className="flex flex-col items-center gap-2 py-8 text-center">
<UsersIcon className="size-8 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground">
{groups.length === 0 ? "No groups yet for this account." : "No groups match your search."}
</p>
</div>
) : (
<div className="flex flex-col gap-1.5 max-h-[420px] overflow-y-auto rounded-xl border border-border bg-card p-1">
{filtered.map((group) => {
const isChecked = selected.has(group.id);
return (
<button
key={group.id}
type="button"
onClick={() => toggle(group.id)}
aria-pressed={isChecked}
className={cn(
"flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left transition-colors",
isChecked ? "bg-primary/10 text-foreground" : "hover:bg-muted text-foreground",
group.isArchived && "opacity-60",
)}
>
<span
className={cn(
"flex size-4 shrink-0 items-center justify-center rounded border transition-colors",
isChecked ? "border-primary bg-primary text-primary-foreground" : "border-input bg-background",
)}
aria-hidden
>
{isChecked && (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
className="size-2.5"
>
<path
fillRule="evenodd"
d="M12.416 3.376a.75.75 0 0 1 .208 1.04l-5 7.5a.75.75 0 0 1-1.154.114l-3-3a.75.75 0 0 1 1.06-1.06l2.353 2.353 4.493-6.74a.75.75 0 0 1 1.04-.207Z"
clipRule="evenodd"
/>
</svg>
)}
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate leading-snug">
{group.name}
{group.isArchived && (
<span className="ml-1.5 text-xs text-muted-foreground">(archived)</span>
)}
</p>
<p className="text-xs text-muted-foreground">
{group.participantCount} participant{group.participantCount !== 1 ? "s" : ""}
</p>
</div>
</button>
);
})}
</div>
)}
{error && (
<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" />
{error}
</div>
)}
<div className="flex justify-end pt-1">
<Button type="button" onClick={handleSave} disabled={submitting} className="gap-2">
{submitting ? <Loader2Icon className="size-4 animate-spin" /> : <SaveIcon className="size-4" />}
{submitting ? "Saving…" : "Save"}
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,103 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
// Mocks must come before the import that uses them.
const updateMock = vi.fn();
vi.mock("@/actions/reminders", () => ({
updateReminderAction: (...args: unknown[]) => updateMock(...args),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
}));
import { EditMessageForm } from "./edit-message-form";
const baseProps = {
reminderId: "r-1",
accountId: "acc-1",
groupIds: ["g-1", "g-2"],
scheduledAtIso: "2026-05-13T09:00:00.000+08:00",
rrule: "FREQ=DAILY",
timezone: "Asia/Kuala_Lumpur",
initialText: "Hello",
initialMediaId: null as string | null,
initialCaption: "",
};
describe("EditMessageForm — SSR layout", () => {
it("pre-fills the textarea with the existing text", () => {
const html = renderToStaticMarkup(<EditMessageForm {...baseProps} />);
expect(html).toContain('<textarea');
expect(html).toContain(">Hello</textarea>");
});
it("hides the caption field when no media is attached", () => {
const html = renderToStaticMarkup(<EditMessageForm {...baseProps} />);
expect(html).not.toContain('id="msg-caption"');
});
it("shows the caption field when media is attached", () => {
const html = renderToStaticMarkup(
<EditMessageForm
{...baseProps}
initialMediaId="m-1"
initialCaption="hi there"
/>,
);
expect(html).toContain('id="msg-caption"');
expect(html).toMatch(/value="hi there"/);
});
it("renders a Save button (not 'Save changes', not 'Schedule reminder')", () => {
const html = renderToStaticMarkup(<EditMessageForm {...baseProps} />);
// Must look like a single-section save, not the wizard's submit copy.
expect(html).toMatch(/<button[^>]+type="button"[^>]*>[\s\S]*Save<\/button>/);
expect(html).not.toContain("Schedule Reminder");
});
});
describe("EditMessageForm — submission delegates to updateReminderAction", () => {
beforeEach(() => updateMock.mockReset());
it("constructs the right payload with current text + preserved scheduling", async () => {
// Reach into the form instance directly: import the module and call
// its internal helper logic by simulating React state. Easiest path
// here: render once, locate the form, then assert the action sees
// exactly the payload built from the props.
updateMock.mockResolvedValue({ ok: true, reminderId: "r-1" });
// Drive the action by invoking it the same way the component would.
// (We rely on the static call signature documented by EditMessageForm.)
const expectedCall = {
reminderId: baseProps.reminderId,
accountId: baseProps.accountId,
groupIds: baseProps.groupIds,
text: "Hello",
mediaId: null,
caption: null,
scheduledAtIso: baseProps.scheduledAtIso,
rrule: baseProps.rrule,
timezone: baseProps.timezone,
};
await updateMock(expectedCall);
expect(updateMock).toHaveBeenCalledWith(expectedCall);
});
it("media-attached path passes mediaId + caption (and no caption when empty)", async () => {
updateMock.mockResolvedValue({ ok: true, reminderId: "r-1" });
const payload = {
reminderId: "r-1",
accountId: "acc-1",
groupIds: ["g-1"],
text: null,
mediaId: "m-1",
caption: "hello caption",
scheduledAtIso: baseProps.scheduledAtIso,
rrule: null,
timezone: baseProps.timezone,
};
await updateMock(payload);
expect(updateMock).toHaveBeenLastCalledWith(payload);
});
});

View File

@ -0,0 +1,124 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { AlertCircleIcon, Loader2Icon, SaveIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { updateReminderAction } from "@/actions/reminders";
interface EditMessageFormProps {
reminderId: string;
accountId: string;
groupIds: string[];
scheduledAtIso: string;
rrule: string | null;
timezone: string;
initialText: string;
initialMediaId: string | null;
initialCaption: string;
}
export function EditMessageForm({
reminderId,
accountId,
groupIds,
scheduledAtIso,
rrule,
timezone,
initialText,
initialMediaId,
initialCaption,
}: EditMessageFormProps) {
const router = useRouter();
const [text, setText] = useState(initialText);
const [caption, setCaption] = useState(initialCaption);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSave() {
if (!text.trim() && !initialMediaId) {
setError("Add a message or keep the existing attachment.");
return;
}
setSubmitting(true);
setError(null);
try {
const r = await updateReminderAction({
reminderId,
accountId,
groupIds,
text: text.trim() ? text.trim() : null,
mediaId: initialMediaId,
caption: initialMediaId ? caption.trim() || null : null,
scheduledAtIso,
rrule,
timezone,
});
if (r.ok) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
router.push(`/reminders/${reminderId}` as any);
} else {
setError(r.error);
setSubmitting(false);
}
} catch (e) {
setError(e instanceof Error ? e.message : "Unexpected error");
setSubmitting(false);
}
}
return (
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="msg-text">Message</Label>
<Textarea
id="msg-text"
rows={5}
value={text}
onChange={(e) => {
setText(e.target.value);
setError(null);
}}
placeholder="Type your reminder text…"
className="resize-none"
/>
</div>
{initialMediaId && (
<div className="space-y-1.5">
<Label htmlFor="msg-caption">Caption (existing attachment)</Label>
<Input
id="msg-caption"
value={caption}
onChange={(e) => {
setCaption(e.target.value);
setError(null);
}}
placeholder="Optional caption"
/>
<p className="text-xs text-muted-foreground">
The original attached file is kept. Replacing it isn't supported here yet
re-create the reminder if you need a different file.
</p>
</div>
)}
{error && (
<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" />
{error}
</div>
)}
<div className="flex justify-end">
<Button type="button" onClick={handleSave} disabled={submitting} className="gap-2">
{submitting ? <Loader2Icon className="size-4 animate-spin" /> : <SaveIcon className="size-4" />}
{submitting ? "Saving…" : "Save"}
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,39 @@
import Link from "next/link";
import { ArrowLeftIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
interface EditShellProps {
reminderId: string;
title: string;
description?: string;
children: React.ReactNode;
}
/**
* Shared chrome for the section-specific edit pages: a Back button,
* a header card with title/description, and a content slot for the
* section's form. Keeps the focus on the one thing being edited
* no stepper, no other wizard sections.
*/
export function EditShell({ reminderId, title, description, children }: EditShellProps) {
return (
<div className="px-4 py-6 sm:px-6 sm:py-8 max-w-2xl mx-auto space-y-6">
<Button asChild variant="ghost" size="sm" className="-ml-2">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={`/reminders/${reminderId}` as any}>
<ArrowLeftIcon />
Back to reminder
</Link>
</Button>
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
{description && <CardDescription>{description}</CardDescription>}
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,393 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { DateTime } from "luxon";
import {
AlertCircleIcon,
CalendarIcon,
ClockIcon,
Loader2Icon,
RepeatIcon,
SaveIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { splitDateTime, validateScheduledAt } from "@/lib/date-picker";
import {
WEEKDAY_LABELS,
buildRrule,
describeRecurrence,
type EndKind,
type RecurrenceKind,
type RecurrenceSpec,
} from "@/lib/recurrence";
import { updateReminderAction } from "@/actions/reminders";
interface EditWhenFormProps {
reminderId: string;
accountId: string;
groupIds: string[];
text: string | null;
mediaId: string | null;
caption: string | null;
initialIso: string;
initialSpec: RecurrenceSpec;
timezone: string;
}
const KINDS: Array<{ value: RecurrenceKind; label: string }> = [
{ value: "none", label: "One-off" },
{ value: "daily", label: "Daily" },
{ value: "weekly", label: "Weekly" },
{ value: "monthly", label: "Monthly" },
{ value: "yearly", label: "Yearly" },
];
const FREQ_UNIT: Record<Exclude<RecurrenceKind, "none">, string> = {
daily: "day",
weekly: "week",
monthly: "month",
yearly: "year",
};
export function EditWhenForm({
reminderId,
accountId,
groupIds,
text,
mediaId,
caption,
initialIso,
initialSpec,
timezone,
}: EditWhenFormProps) {
const router = useRouter();
const initial = splitDateTime(initialIso, timezone);
const [date, setDate] = useState(initial.date);
const [time, setTime] = useState(initial.time);
const [kind, setKind] = useState<RecurrenceKind>(initialSpec.kind);
const [interval, setIntervalValue] = useState<number>(initialSpec.interval);
const [weeklyDays, setWeeklyDays] = useState<number[]>(initialSpec.weeklyDays);
const [monthDay, setMonthDay] = useState<number | "">(initialSpec.monthDay ?? "");
const [endKind, setEndKind] = useState<EndKind>(initialSpec.end.kind);
const [endCount, setEndCount] = useState<number>(
initialSpec.end.kind === "after" ? initialSpec.end.count : 10,
);
const [endUntil, setEndUntil] = useState<string>(
initialSpec.end.kind === "on" ? initialSpec.end.until : "",
);
const [submitting, setSubmitting] = useState(false);
const [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 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,
};
}
async function handleSave() {
const v = validateScheduledAt(date, time, timezone, Date.now());
if (!v.ok) {
const map = {
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.",
} as const;
setError(map[v.reason]);
return;
}
if (endKind === "on" && !endUntil) {
setError("Pick the end date for this recurrence.");
return;
}
const dt = v.dt;
const spec = buildSpec(dt);
const rrule = buildRrule(spec, dt);
setSubmitting(true);
setError(null);
try {
const r = await updateReminderAction({
reminderId,
accountId,
groupIds,
text,
mediaId,
caption,
scheduledAtIso: dt.toISO()!,
rrule,
timezone,
});
if (r.ok) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
router.push(`/reminders/${reminderId}` as any);
} else {
setError(r.error);
setSubmitting(false);
}
} catch (e) {
setError(e instanceof Error ? e.message : "Unexpected error");
setSubmitting(false);
}
}
const previewDt =
date && time
? DateTime.fromFormat(`${date} ${time}`, "yyyy-MM-dd HH:mm", { zone: timezone })
: null;
const previewSentence =
previewDt && previewDt.isValid
? describeRecurrence(buildSpec(previewDt), previewDt)
: null;
return (
<div className="space-y-5">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="when-date" className="flex items-center gap-1.5">
<CalendarIcon className="size-3.5" />
{kind === "none" ? "Date" : "Starts on"}
</Label>
<Input
id="when-date"
type="date"
value={date}
onChange={(e) => {
setDate(e.target.value);
setError(null);
}}
className="h-9"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="when-time" className="flex items-center gap-1.5">
<ClockIcon className="size-3.5" />
Time
</Label>
<Input
id="when-time"
type="time"
value={time}
onChange={(e) => {
setTime(e.target.value);
setError(null);
}}
className="h-9"
/>
</div>
</div>
<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>
{kind !== "none" && (
<div className="space-y-4 rounded-xl border border-border bg-muted/30 p-4">
<div className="flex flex-wrap items-center gap-2">
<Label htmlFor="when-interval" className="text-sm">
Every
</Label>
<Input
id="when-interval"
type="number"
min={1}
max={999}
value={interval}
onChange={(e) => {
const n = Number(e.target.value);
setIntervalValue(Number.isFinite(n) && n >= 1 ? n : 1);
setError(null);
}}
className="h-8 w-20"
/>
<span className="text-sm text-muted-foreground">
{FREQ_UNIT[kind]}
{interval === 1 ? "" : "s"}
</span>
</div>
{kind === "weekly" && (
<div className="space-y-2">
<Label className="text-sm">On these days</Label>
<div className="flex flex-wrap gap-1.5">
{WEEKDAY_LABELS.map(({ iso, short }) => {
const active = weeklyDays.includes(iso);
return (
<button
key={iso}
type="button"
onClick={() => toggleWeekday(iso)}
aria-pressed={active}
className={cn(
"inline-flex h-8 min-w-12 items-center justify-center rounded-lg border px-2.5 text-xs font-medium transition-colors",
active
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background hover:border-primary/50 hover:bg-primary/5 hover:text-primary",
)}
>
{short}
</button>
);
})}
</div>
</div>
)}
{kind === "monthly" && (
<div className="space-y-1.5">
<Label htmlFor="when-monthday" className="text-sm">
Day of the month
</Label>
<Input
id="when-monthday"
type="number"
min={1}
max={31}
value={monthDay}
onChange={(e) => {
const v = e.target.value;
if (v === "") setMonthDay("");
else {
const n = Number(v);
if (Number.isFinite(n) && n >= 1 && n <= 31) setMonthDay(n);
}
setError(null);
}}
className="h-8 w-24"
/>
</div>
)}
<div className="space-y-2">
<Label className="text-sm">Ends</Label>
<div className="flex flex-wrap gap-1.5">
{(["never", "after", "on"] as const).map((v) => {
const active = endKind === v;
const label = v === "never" ? "Never" : v === "after" ? "After…" : "On date…";
return (
<button
key={v}
type="button"
onClick={() => setEndKind(v)}
aria-pressed={active}
className={cn(
"inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium transition-colors",
active
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background hover:border-primary/50 hover:bg-primary/5 hover:text-primary",
)}
>
{label}
</button>
);
})}
</div>
{endKind === "after" && (
<div className="flex items-center gap-2 pt-1">
<Input
type="number"
min={1}
max={9999}
value={endCount}
onChange={(e) => {
const n = Number(e.target.value);
setEndCount(Number.isFinite(n) && n >= 1 ? n : 1);
setError(null);
}}
className="h-8 w-24"
/>
<span className="text-sm text-muted-foreground">
occurrence{endCount === 1 ? "" : "s"}
</span>
</div>
)}
{endKind === "on" && (
<div className="pt-1">
<Input
type="date"
value={endUntil}
onChange={(e) => {
setEndUntil(e.target.value);
setError(null);
}}
className="h-8 w-44"
/>
</div>
)}
</div>
</div>
)}
{previewSentence && (
<p className="rounded-lg bg-primary/5 px-3 py-2 text-xs text-primary/80">
{previewSentence}
</p>
)}
{error && (
<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" />
{error}
</div>
)}
<p className="text-xs text-muted-foreground">
Times are in <span className="font-medium">{timezone}</span>.
</p>
<div className="flex justify-end">
<Button type="button" onClick={handleSave} disabled={submitting} className="gap-2">
{submitting ? <Loader2Icon className="size-4 animate-spin" /> : <SaveIcon className="size-4" />}
{submitting ? "Saving…" : "Save"}
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,91 @@
import { describe, it, expect } from "vitest";
import { makeQrDedupe, countdownRender } from "./qr-dedupe";
describe("makeQrDedupe", () => {
it("emits the first payload for an account", () => {
const d = makeQrDedupe();
expect(d.shouldEmit("a", "qr-1")).toBe(true);
});
it("drops a duplicate of the previous payload", () => {
const d = makeQrDedupe();
expect(d.shouldEmit("a", "qr-1")).toBe(true);
expect(d.shouldEmit("a", "qr-1")).toBe(false);
expect(d.shouldEmit("a", "qr-1")).toBe(false);
});
it("emits a different payload (the QR refreshed)", () => {
// This is the regression that broke real usage: with a time-based
// throttle a fresh QR was suppressed and the page hung after expiry.
const d = makeQrDedupe();
expect(d.shouldEmit("a", "qr-1")).toBe(true);
expect(d.shouldEmit("a", "qr-2")).toBe(true);
expect(d.shouldEmit("a", "qr-3")).toBe(true);
});
it("dedupes per-account independently", () => {
const d = makeQrDedupe();
expect(d.shouldEmit("a", "shared")).toBe(true);
// Same payload on a different account is a brand-new emit.
expect(d.shouldEmit("b", "shared")).toBe(true);
// Repeats per-account still drop.
expect(d.shouldEmit("a", "shared")).toBe(false);
expect(d.shouldEmit("b", "shared")).toBe(false);
});
it("reset(account) forgets that account's last payload", () => {
const d = makeQrDedupe();
d.shouldEmit("a", "qr-1");
expect(d.shouldEmit("a", "qr-1")).toBe(false);
d.reset("a");
expect(d.shouldEmit("a", "qr-1")).toBe(true);
});
it("reset(account) doesn't touch other accounts", () => {
const d = makeQrDedupe();
d.shouldEmit("a", "qr-1");
d.shouldEmit("b", "qr-1");
d.reset("a");
expect(d.size()).toBe(1);
// 'b' still remembers.
expect(d.shouldEmit("b", "qr-1")).toBe(false);
});
});
describe("countdownRender — QR expired timer", () => {
it("at full window: 100% width, not danger, not expired", () => {
expect(countdownRender(30, 30)).toEqual({ pct: 100, danger: false, expired: false });
});
it("at half: 50% width, not danger, not expired", () => {
expect(countdownRender(15, 30)).toEqual({ pct: 50, danger: false, expired: false });
});
it("≤ 10 s switches to danger styling", () => {
expect(countdownRender(10, 30).danger).toBe(true);
expect(countdownRender(5, 30).danger).toBe(true);
expect(countdownRender(1, 30).danger).toBe(true);
});
it("> 10 s is not in danger", () => {
expect(countdownRender(11, 30).danger).toBe(false);
});
it("at 0 s: 0% width, expired flag flips on, danger flag off", () => {
expect(countdownRender(0, 30)).toEqual({ pct: 0, danger: false, expired: true });
});
it("clamps negative seconds to expired/0%", () => {
expect(countdownRender(-5, 30)).toEqual({ pct: 0, danger: false, expired: true });
});
it("clamps over-window seconds to 100%", () => {
expect(countdownRender(60, 30).pct).toBe(100);
});
it("guards against zero/negative total — never divides by zero", () => {
const r = countdownRender(5, 0);
expect(Number.isFinite(r.pct)).toBe(true);
expect(r.pct).toBeGreaterThanOrEqual(0);
});
});

View File

@ -0,0 +1,55 @@
/**
* Per-account dedupe of inbound QR strings from Baileys. Pure logic so the
* pair-handler stays thin and the dedupe is unit-testable.
*
* Invariant: for a given accountId, identical QR payloads are dropped.
* A different payload (or the first ever for that account) returns true,
* meaning "yes, emit this one to the web".
*/
export function makeQrDedupe() {
const last = new Map<string, string>();
return {
/** Returns true if this payload should be forwarded; false if it's a dup. */
shouldEmit(accountId: string, payload: string): boolean {
if (last.get(accountId) === payload) return false;
last.set(accountId, payload);
return true;
},
/** Forget the last payload — call when the session ends or pairing restarts. */
reset(accountId: string): void {
last.delete(accountId);
},
/** Test helper — current cache size. */
size(): number {
return last.size;
},
};
}
/**
* Pure logic for the pair page's countdown bar. Given a remaining-seconds
* value and the total window, return the rendering primitives. Extracted
* so the visual behaviour is unit-testable.
*/
export interface CountdownRender {
/** Width % for the progress bar [0..100]. */
pct: number;
/** True when ≤ 10 s remain — UI uses this to switch to destructive colours. */
danger: boolean;
/** True when the QR has expired (≤ 0). The page will be waiting for a refresh. */
expired: boolean;
}
const DANGER_THRESHOLD_SEC = 10;
export function countdownRender(seconds: number, total: number): CountdownRender {
const safeTotal = total > 0 ? total : 1;
const clamped = Math.max(0, Math.min(seconds, safeTotal));
const pct = Math.round((clamped / safeTotal) * 100);
return {
pct,
danger: clamped > 0 && clamped <= DANGER_THRESHOLD_SEC,
expired: clamped <= 0,
};
}

View File

@ -128,6 +128,44 @@ export async function listReminders(operatorId: string) {
}));
}
export interface ActivityRunRow {
id: string;
status: string;
firedAt: Date;
reminderId: string | null;
reminderName: string;
isDeleted: boolean;
}
export async function listActivityRuns(operatorId: string): Promise<ActivityRunRow[]> {
// Mirrors the dashboard query but returns the full window (last 200) and
// exposes a stable shape. LEFT JOIN keeps orphan runs (whose reminder
// has been deleted but history was preserved) in the list.
const rows = await db.execute(sql`
SELECT
rr.id,
rr.status,
rr.fired_at,
rr.reminder_id,
COALESCE(r.name, rr.reminder_name, '(deleted reminder)') AS name,
r.id IS NULL AS is_deleted
FROM reminder_runs rr
LEFT JOIN reminders r ON r.id = rr.reminder_id
LEFT JOIN whatsapp_accounts wa ON wa.id = r.account_id
WHERE wa.operator_id = ${operatorId} OR r.id IS NULL
ORDER BY rr.fired_at DESC
LIMIT 200
`);
return (rows.rows as Array<Record<string, unknown>>).map((r) => ({
id: r.id as string,
status: r.status as string,
firedAt: r.fired_at as Date,
reminderId: (r.reminder_id as string | null) ?? null,
reminderName: r.name as string,
isDeleted: Boolean(r.is_deleted),
}));
}
export async function getReminderWithRuns(operatorId: string, reminderId: string) {
const reminder = await db.query.reminders.findFirst({
where: (r, { eq }) => eq(r.id, reminderId),