yiekheng bb8d28a594 feat(web): paused-run banner + Activity Paused filter / Resume button
Reminder detail page:

* Surfaces a PausedRunBanner above the rest of the surface when the
  most recent run is in 'paused' state. The banner shows the
  delivered/total counts, the deadline that closed the window, and
  Resume / Cancel run buttons that call the matching server actions.
* getReminderWithRuns now LEFT JOIN-aggregates run_target counts so
  the banner has sent/total per run without an N+1 fan-out.

Activity tab:

* New Paused filter tab between Success and Partial.
* Paused rows in the desktop table get an inline ResumeRunButton
  (emerald play icon, useTransition + error surfacing).
* RunStatusBadge picks up a Paused entry — amber, PauseCircle icon.

Tests:
* PausedRunBanner — 4 SSR cases (resume/cancel CTA rendered, X-of-Y
  copy, generic fallback, amber styling).
* ResumeRunButton — 4 SSR cases (aria, emerald accent, compact /
  default size variants).

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

441 lines
16 KiB
TypeScript

import Link from "next/link";
import {
ActivityIcon,
AlertTriangleIcon,
ArchiveIcon,
ArchiveRestoreIcon,
CheckCircle2Icon,
MinusCircleIcon,
PauseCircleIcon,
PlayIcon,
Trash2Icon,
XCircleIcon,
} 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 { PageShell } from "@/components/page-shell";
import { EmptyState } from "@/components/empty-state";
import { getSeededOperator } from "@/lib/operator";
import { listActivityRuns } from "@/lib/queries";
import {
archiveRunAction,
clearHistoryAction,
deleteRunAction,
unarchiveRunAction,
} from "@/actions/history";
import { SwipeableRow } from "@/components/swipeable-row";
import { ResumeRunButton } from "@/components/activity/resume-run-button";
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,
},
paused: {
label: "Paused",
className:
"bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400 border-transparent",
icon: PauseCircleIcon,
},
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"
| "paused"
| "partial"
| "failed"
| "skipped"
| "archived";
const FILTER_TABS: { value: FilterValue; label: string }[] = [
{ value: "all", label: "All" },
{ value: "success", label: "Success" },
{ value: "paused", label: "Paused" },
{ value: "partial", label: "Partial" },
{ value: "failed", label: "Failed" },
{ value: "skipped", label: "Skipped" },
{ value: "archived", label: "Archived" },
];
interface PageProps {
searchParams: Promise<{ filter?: string }>;
}
interface ShelfButtonProps {
runId: string;
isArchived: boolean;
}
/**
* Left-shelf (revealed by swiping the row RIGHT). Hard-delete button.
* iOS-Mail-style: destructive action lives on the leading edge.
*/
function DeleteShelfButton({ runId }: ShelfButtonProps) {
return (
<form action={deleteRunAction} className="flex w-full">
<input type="hidden" name="runId" value={runId} />
<button
type="submit"
aria-label="Delete"
className="flex h-full w-full flex-col items-center justify-center gap-1 bg-destructive/15 text-destructive hover:bg-destructive/25 text-xs font-medium"
>
<Trash2Icon className="size-4" />
Delete
</button>
</form>
);
}
/**
* Right-shelf (revealed by swiping the row LEFT). Archive (or Restore
* when the row is already archived). Non-destructive trailing action.
*/
function ArchiveShelfButton({ runId, isArchived }: ShelfButtonProps) {
return (
<form
action={isArchived ? unarchiveRunAction : archiveRunAction}
className="flex w-full"
>
<input type="hidden" name="runId" value={runId} />
<button
type="submit"
aria-label={isArchived ? "Restore" : "Archive"}
className="flex h-full w-full flex-col items-center justify-center gap-1 bg-amber-500/15 text-amber-700 hover:bg-amber-500/25 dark:bg-amber-500/20 dark:text-amber-400 dark:hover:bg-amber-500/30 text-xs font-medium"
>
{isArchived ? (
<ArchiveRestoreIcon className="size-4" />
) : (
<ArchiveIcon className="size-4" />
)}
{isArchived ? "Restore" : "Archive"}
</button>
</form>
);
}
export default async function ActivityPage({ searchParams }: PageProps) {
const sp = await searchParams;
const filter: FilterValue =
sp.filter === "success" ||
sp.filter === "paused" ||
sp.filter === "partial" ||
sp.filter === "failed" ||
sp.filter === "skipped" ||
sp.filter === "archived"
? sp.filter
: "all";
const showingArchived = filter === "archived";
const op = await getSeededOperator();
const runs = await listActivityRuns(op.id, { archived: showingArchived });
const filtered =
filter === "all" || filter === "archived"
? runs
: runs.filter((r) => r.status === filter);
const hasAny = runs.length > 0;
return (
<PageShell
title="Activity"
action={
hasAny && !showingArchived ? (
<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>
) : undefined
}
>
{/* Six tabs (All / Success / Partial / Failed / Skipped / Archived)
packed into a phone-width row left every label squeezed to
~50px. Wrap the list in an overflow-x scroller so each tab
keeps a readable label + comfortable touch target on mobile;
on desktop the row fits naturally and no scroll bar appears.
Negative margins extend the scroller to the page edges so the
first/last tabs don't look clipped against the container. */}
<Tabs value={filter}>
<div className="-mx-4 overflow-x-auto px-4 sm:mx-0 sm:px-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<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>
</div>
</Tabs>
{filtered.length > 0 ? (
<>
<p className="text-xs text-muted-foreground sm:hidden">
Swipe left to Delete, or right to {showingArchived ? "Restore" : "Archive"}.
</p>
{/* Mobile: swipeable cards */}
<div className="flex flex-col gap-2 sm:hidden">
{filtered.map((run) => {
const clickable = run.reminderId && !run.isDeleted;
const inner = (
<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>
);
const card = (
<Card
size="sm"
className={
clickable
? "transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer rounded-none border-0 ring-0"
: "rounded-none border-0 ring-0"
}
>
{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:ring-2 focus-visible:ring-ring"
>
{inner}
</Link>
) : (
inner
)}
</Card>
);
return (
<SwipeableRow
// Key includes the archived flag so flipping it
// remounts the row with a fresh offset (closed shelf).
key={`${run.id}-${run.archivedAt ? "1" : "0"}`}
// Right swipe → reveal left shelf → Archive (non-destructive).
leftActions={
<ArchiveShelfButton runId={run.id} isArchived={Boolean(run.archivedAt)} />
}
// Left swipe → reveal right shelf → Delete (destructive).
rightActions={<DeleteShelfButton runId={run.id} isArchived={Boolean(run.archivedAt)} />}
>
{card}
</SwipeableRow>
);
})}
</div>
{/* Desktop: table with hover-revealed actions */}
<div className="hidden sm:block">
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Reminder</TableHead>
<TableHead>Status</TableHead>
<TableHead>Fired</TableHead>
<TableHead className="w-1 text-right pr-4">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((run) => {
const clickable = run.reminderId && !run.isDeleted;
const isArchived = Boolean(run.archivedAt);
return (
<TableRow
key={run.id}
className={clickable ? "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-muted-foreground text-xs whitespace-nowrap">
{relativeTime(run.firedAt)}
</TableCell>
<TableCell className="text-right pr-2 whitespace-nowrap">
<div className="inline-flex items-center gap-0.5">
{run.status === "paused" && (
<ResumeRunButton runId={run.id} />
)}
<form
action={
isArchived ? unarchiveRunAction : archiveRunAction
}
>
<input type="hidden" name="runId" value={run.id} />
<Button
type="submit"
variant="ghost"
size="icon-sm"
aria-label={isArchived ? "Restore" : "Archive"}
className="text-muted-foreground hover:text-amber-700 dark:hover:text-amber-400"
>
{isArchived ? (
<ArchiveRestoreIcon className="size-4" />
) : (
<ArchiveIcon className="size-4" />
)}
</Button>
</form>
<form action={deleteRunAction}>
<input type="hidden" name="runId" value={run.id} />
<Button
type="submit"
variant="ghost"
size="icon-sm"
aria-label="Delete"
className="text-muted-foreground hover:text-destructive"
>
<Trash2Icon className="size-4" />
</Button>
</form>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
</>
) : (
<EmptyState
icon={ActivityIcon}
title={
filter === "all"
? "No activity yet."
: showingArchived
? "No archived runs."
: `No ${filter} runs yet.`
}
description={
hasAny
? "Runs in other states aren't shown by this filter."
: "Reminder fire events will appear here."
}
/>
)}
</PageShell>
);
}