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>
441 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|