yiekheng ba9e50fec0 feat: dashboard navigation, preserve run history, QR refresh fix
Dashboard
- Stat cards are now clickable: Accounts → /accounts, Active reminders →
  /reminders?filter=active, Recent runs → /reminders.
- Recent activity rows link to the underlying reminder when it still
  exists. Runs whose reminder has been deleted render with a "(deleted)"
  marker and stay non-clickable.
- New "Clear history" action wipes all run rows the operator owns plus
  any orphan rows (reminderId=NULL).

Run history persists after reminder delete
- reminder_runs.reminder_id is now nullable with ON DELETE SET NULL, so
  deleting a reminder no longer cascade-erases its history.
- New reminder_runs.reminder_name column snapshots the name at fire
  time so history rows stay readable even after the reminder is gone.
- Fire-reminder records the snapshot.
- Dashboard query LEFT JOINs and COALESCEs name from the live reminder,
  the snapshot, or "(deleted reminder)" as last resort.

QR
- Drop the 25 s server-side throttle. With listener accumulation already
  fixed (previous commit), the payload-equality dedupe is enough.
  Symptom: after the first QR expired the throttle blocked the next
  emit, and the QR never refreshed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 01:27:53 +08:00

330 lines
12 KiB
TypeScript

import Link from "next/link";
import {
WifiIcon,
BellIcon,
ActivityIcon,
CheckCircle2Icon,
AlertTriangleIcon,
XCircleIcon,
MinusCircleIcon,
Trash2Icon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { clearHistoryAction } from "@/actions/history";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { getSeededOperator } from "@/lib/operator";
import { getDashboardStats } from "@/lib/queries";
// ---------------------------------------------------------------------------
// Relative time helper (no external dep, server-safe)
// ---------------------------------------------------------------------------
function relativeTime(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
const diffMs = Date.now() - d.getTime();
const diffSec = Math.floor(diffMs / 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");
}
// ---------------------------------------------------------------------------
// Run-status pill
// ---------------------------------------------------------------------------
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>
);
}
// ---------------------------------------------------------------------------
// Stat card — entire card is the link to its tab
// ---------------------------------------------------------------------------
function StatCard({
title,
value,
icon: Icon,
description,
href,
}: {
title: string;
value: string | number;
icon: React.ElementType;
description?: string;
href: string;
}) {
return (
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={href as any}
className="block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<Card className="h-full transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer">
<CardHeader>
<div className="flex items-center justify-between gap-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{title}
</CardTitle>
<Icon className="size-4 text-muted-foreground shrink-0" />
</div>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold tabular-nums">{value}</p>
{description && (
<CardDescription className="mt-1 text-xs">{description}</CardDescription>
)}
</CardContent>
</Card>
</Link>
);
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function DashboardPage() {
const op = await getSeededOperator();
const stats = await getDashboardStats(op.id);
const hasRuns = stats.recentRuns.length > 0;
return (
<div className="px-4 py-6 sm:px-6 sm:py-8 max-w-5xl mx-auto space-y-8">
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
{/* Stat cards — click to drill into the corresponding tab */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<StatCard
title="Accounts connected"
value={`${stats.connectedAccounts} / ${stats.totalAccounts}`}
icon={WifiIcon}
description="WhatsApp accounts"
href="/accounts"
/>
<StatCard
title="Active reminders"
value={stats.activeReminders}
icon={BellIcon}
description="Scheduled & running"
href="/reminders?filter=active"
/>
<StatCard
title="Recent runs"
value={stats.recentRuns.length}
icon={ActivityIcon}
description="Last 10 reminder runs"
href="/reminders"
/>
</div>
{/* Recent activity */}
<section className="space-y-4">
<div className="flex items-center justify-between gap-2">
<h2 className="text-lg font-medium tracking-tight">Recent activity</h2>
{hasRuns && (
<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>
{hasRuns ? (
<>
{/* Mobile: card list — clickable when the reminder still exists */}
<div className="flex flex-col gap-3 sm:hidden">
{stats.recentRuns.map((run) => {
const body = (
<Card size="sm" className={run.is_deleted ? undefined : "transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer"}>
<CardContent className="flex items-center justify-between gap-3 py-3">
<div className="min-w-0">
<p className="text-sm font-medium truncate">
{run.name}
{run.is_deleted && (
<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.fired_at)}
</p>
</div>
<RunStatusBadge status={run.status} />
</CardContent>
</Card>
);
return run.reminder_id && !run.is_deleted ? (
<Link
key={run.id}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/reminders/${run.reminder_id}` 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 — rows are clickable when reminder still exists */}
<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>
{stats.recentRuns.map((run) => {
const clickable = run.reminder_id && !run.is_deleted;
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.reminder_id}` as any}
className="block focus-visible:outline-none focus-visible:underline"
>
{run.name}
</Link>
) : (
<span className="text-muted-foreground italic">
{run.name}
</span>
)}
</TableCell>
<TableCell>
<RunStatusBadge status={run.status} />
</TableCell>
<TableCell className="text-right text-muted-foreground text-xs">
{relativeTime(run.fired_at)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
</>
) : (
<Card>
<CardContent className="flex flex-col items-center gap-4 py-12 text-center">
<ActivityIcon className="size-10 text-muted-foreground/40" />
<div className="space-y-1">
<p className="text-sm font-medium">No reminders have fired yet.</p>
<p className="text-xs text-muted-foreground">
Schedule one to start sending WhatsApp messages.
</p>
</div>
<Button asChild size="sm">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={"/reminders/new" as any}>Schedule a reminder</Link>
</Button>
</CardContent>
</Card>
)}
</section>
</div>
);
}