12 new SSR tests in app-shell.test.tsx:
Mobile header
- Fixed top header is rendered with `sm:hidden` so it disappears on
the desktop breakpoint.
- Brand mark on the left links home and carries `aria-label="Go home"`.
- Page title in the centre is derived from usePathname:
* "/" → "Dashboard"
* "/accounts/123" → "Accounts" (sub-route falls back to parent label)
* unknown route → generic "WhatsApp Bot"
- Menu button on the right is labelled `aria-label="Open menu"`.
Menu drawer (Sheet primitives mocked transparent so SSR shows content)
- Renders one nav link per NAV_ITEM, in declared order.
- The active route's link gets `aria-current="page"`; others don't.
- Dashboard ("/") matches by exact equality, not by `startsWith`, so
every page doesn't get marked Dashboard.
- The drawer does NOT include the theme toggle — it lives only in
the desktop sidebar footer per the recent product call.
- Drawer header carries the brand wording and the SR-only nav-menu
description.
Desktop sidebar
- Renders with `hidden sm:flex` (mobile-hidden, desktop-visible).
- All NAV_ITEMS appear.
- Theme toggle is present in the sidebar footer.
Plus the small follow-up the user pointed at:
UI: status tabs span the full row
- The shadcn `<TabsList>` defaults to `inline-flex w-fit`, which
packed Active/Ended/Paused into a tight cluster on the left of
the reminders + activity pages. Added `w-full` to both
`<TabsList>` invocations so the tabs distribute evenly across
the available row width (`flex-1` on each `<TabsTrigger>` already
handles even widths once the parent stretches).
Total: 206 web tests passing (was 194; +12 from app-shell.test.tsx).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
413 lines
15 KiB
TypeScript
413 lines
15 KiB
TypeScript
import Link from "next/link";
|
|
import {
|
|
ActivityIcon,
|
|
AlertTriangleIcon,
|
|
ArchiveIcon,
|
|
ArchiveRestoreIcon,
|
|
CheckCircle2Icon,
|
|
MinusCircleIcon,
|
|
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 { getSeededOperator } from "@/lib/operator";
|
|
import { listActivityRuns } from "@/lib/queries";
|
|
import {
|
|
archiveRunAction,
|
|
clearHistoryAction,
|
|
deleteRunAction,
|
|
unarchiveRunAction,
|
|
} from "@/actions/history";
|
|
import { SwipeableRow } from "@/components/swipeable-row";
|
|
|
|
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" | "archived";
|
|
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" },
|
|
{ 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 === "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 (
|
|
<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 && !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>
|
|
)}
|
|
</div>
|
|
|
|
<Tabs value={filter}>
|
|
<TabsList className="w-full">
|
|
{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 ? (
|
|
<>
|
|
<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">
|
|
<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>
|
|
</>
|
|
) : (
|
|
<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."
|
|
: showingArchived
|
|
? "No archived runs."
|
|
: `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>
|
|
);
|
|
}
|