yiekheng be3f28a1e6 refactor(web,bot,db): rename reminder status 'ended' → 'inactive'
The 'ended' label read like a terminal failure state ("the reminder
gave up") when in practice it just means "this reminder isn't going
to fire on its own — restart it if you want it back". 'inactive' is
the more accurate read.

* SQL migration 0009 backfills existing rows.
* Bot fire-reminder writes 'inactive' on one-off completion / no
  further occurrences.
* Web actions, queries, filters, and reminder lifecycle gates updated.
* Dashboard counter card label "Active / Paused / Ended / Total"
  becomes "Active / Paused / Inactive / Total".
* Reminders list filter tab "Ended" becomes "Inactive".
* Status pill style key renamed to match.
* Tests updated alongside the runtime changes.

Also: the "Pause sending by" deadline opt-in now renders as a
visible card-shaped row with hover state + Set/Off label on the
right, so the toggle is discoverable instead of a tiny native
checkbox tucked next to the label.

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

353 lines
13 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";
import { PageShell } from "@/components/page-shell";
import { EmptyState } from "@/components/empty-state";
// ---------------------------------------------------------------------------
// Time helpers (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");
}
/** Absolute-time fallback used as a tooltip on relative-time displays.
* 12-hour format with AM/PM so the user can read it at a glance. */
function absoluteTime(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
return new Intl.DateTimeFormat("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
hour: "numeric",
minute: "2-digit",
hour12: true,
}).format(d);
}
// ---------------------------------------------------------------------------
// 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 (
<PageShell title="Dashboard">
{/* Stat cards — click to drill into the corresponding tab */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<StatCard
title="WhatsApp accounts"
value={`${stats.connectedAccounts} / ${stats.unpairedAccounts} / ${stats.totalAccounts}`}
icon={WifiIcon}
description="Connected / Unpaired / Total"
href="/accounts"
/>
<StatCard
title="Reminders"
value={`${stats.activeReminders} / ${stats.pausedReminders} / ${stats.inactiveReminders} / ${stats.totalReminders}`}
icon={BellIcon}
description="Active / Paused / Inactive / Total"
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>
<div className="flex items-center gap-1">
{hasRuns && (
<Button asChild variant="ghost" size="sm" className="text-muted-foreground">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={"/activity" as any}>View all</Link>
</Button>
)}
{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>
</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>
<time
dateTime={new Date(run.fired_at).toISOString()}
title={absoluteTime(run.fired_at)}
className="text-xs text-muted-foreground mt-0.5 block"
>
{absoluteTime(run.fired_at)} · {relativeTime(run.fired_at)}
</time>
</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">
<time
dateTime={new Date(run.fired_at).toISOString()}
title={absoluteTime(run.fired_at)}
>
{absoluteTime(run.fired_at)}
</time>
<span className="block text-[10px] opacity-75">
{relativeTime(run.fired_at)}
</span>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
</>
) : (
<EmptyState
icon={ActivityIcon}
title="No reminders have fired yet."
description="Schedule one to start sending WhatsApp messages."
action={
<Button asChild size="sm">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={"/reminders/new" as any}>Schedule a reminder</Link>
</Button>
}
/>
)}
</section>
</PageShell>
);
}