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>
This commit is contained in:
yiekheng 2026-05-10 15:58:06 +08:00
parent 376bbe595b
commit bb8d28a594
7 changed files with 332 additions and 5 deletions

View File

@ -6,6 +6,8 @@ import {
ArchiveRestoreIcon, ArchiveRestoreIcon,
CheckCircle2Icon, CheckCircle2Icon,
MinusCircleIcon, MinusCircleIcon,
PauseCircleIcon,
PlayIcon,
Trash2Icon, Trash2Icon,
XCircleIcon, XCircleIcon,
} from "lucide-react"; } from "lucide-react";
@ -41,6 +43,7 @@ import {
unarchiveRunAction, unarchiveRunAction,
} from "@/actions/history"; } from "@/actions/history";
import { SwipeableRow } from "@/components/swipeable-row"; import { SwipeableRow } from "@/components/swipeable-row";
import { ResumeRunButton } from "@/components/activity/resume-run-button";
function relativeTime(date: Date | string): string { function relativeTime(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date; const d = typeof date === "string" ? new Date(date) : date;
@ -62,6 +65,12 @@ const RUN_STATUS_CONFIG: Record<
"bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent", "bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent",
icon: CheckCircle2Icon, 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: { partial: {
label: "Partial", label: "Partial",
className: className:
@ -97,10 +106,18 @@ function RunStatusBadge({ status }: { status: string }) {
); );
} }
type FilterValue = "all" | "success" | "partial" | "failed" | "skipped" | "archived"; type FilterValue =
| "all"
| "success"
| "paused"
| "partial"
| "failed"
| "skipped"
| "archived";
const FILTER_TABS: { value: FilterValue; label: string }[] = [ const FILTER_TABS: { value: FilterValue; label: string }[] = [
{ value: "all", label: "All" }, { value: "all", label: "All" },
{ value: "success", label: "Success" }, { value: "success", label: "Success" },
{ value: "paused", label: "Paused" },
{ value: "partial", label: "Partial" }, { value: "partial", label: "Partial" },
{ value: "failed", label: "Failed" }, { value: "failed", label: "Failed" },
{ value: "skipped", label: "Skipped" }, { value: "skipped", label: "Skipped" },
@ -167,6 +184,7 @@ export default async function ActivityPage({ searchParams }: PageProps) {
const sp = await searchParams; const sp = await searchParams;
const filter: FilterValue = const filter: FilterValue =
sp.filter === "success" || sp.filter === "success" ||
sp.filter === "paused" ||
sp.filter === "partial" || sp.filter === "partial" ||
sp.filter === "failed" || sp.filter === "failed" ||
sp.filter === "skipped" || sp.filter === "skipped" ||
@ -354,6 +372,9 @@ export default async function ActivityPage({ searchParams }: PageProps) {
</TableCell> </TableCell>
<TableCell className="text-right pr-2 whitespace-nowrap"> <TableCell className="text-right pr-2 whitespace-nowrap">
<div className="inline-flex items-center gap-0.5"> <div className="inline-flex items-center gap-0.5">
{run.status === "paused" && (
<ResumeRunButton runId={run.id} />
)}
<form <form
action={ action={
isArchived ? unarchiveRunAction : archiveRunAction isArchived ? unarchiveRunAction : archiveRunAction

View File

@ -30,6 +30,7 @@ import {
} from "@/components/ui/table"; } from "@/components/ui/table";
import { getSeededOperator } from "@/lib/operator"; import { getSeededOperator } from "@/lib/operator";
import { getReminderWithRuns } from "@/lib/queries"; import { getReminderWithRuns } from "@/lib/queries";
import { PausedRunBanner } from "@/components/reminder-detail/paused-run-banner";
import { ActionsBar } from "./actions-bar"; import { ActionsBar } from "./actions-bar";
function formatWhen(date: Date | null, tz: string): string { function formatWhen(date: Date | null, tz: string): string {
@ -119,6 +120,22 @@ export default async function ReminderDetailPage({ params }: Props) {
</p> </p>
</div> </div>
{/* Most recent paused run gets a banner — Resume / Cancel are
one click away. Pause notifications deep-link here. */}
{(() => {
const pausedRun = runs.find((r) => r.status === "paused");
if (!pausedRun) return null;
return (
<PausedRunBanner
runId={pausedRun.id}
sent={pausedRun.sent}
total={pausedRun.total}
windowEndHour={reminder.deliveryWindowEndHour}
timezone={reminder.timezone}
/>
);
})()}
<Separator /> <Separator />
{/* Name click to edit. Required field, the operator's {/* Name click to edit. Required field, the operator's

View File

@ -0,0 +1,32 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@/actions/reminders", () => ({
resumeReminderRunAction: vi.fn(),
}));
import { ResumeRunButton } from "./resume-run-button";
describe("ResumeRunButton", () => {
it("renders an icon button with aria-label='Resume run'", () => {
const html = renderToStaticMarkup(<ResumeRunButton runId="r-1" />);
expect(html).toMatch(/aria-label="Resume run"/);
expect(html).toMatch(/lucide-play/);
});
it("uses emerald accent so paused rows clearly offer 'go again'", () => {
const html = renderToStaticMarkup(<ResumeRunButton runId="r-1" />);
expect(html).toMatch(/text-emerald-700/);
});
it("compact variant uses size=icon-sm so it fits inline in the table", () => {
const html = renderToStaticMarkup(<ResumeRunButton runId="r-1" variant="compact" />);
// shadcn button forwards size into a data-size attr.
expect(html).toMatch(/data-size="icon-sm"/);
});
it("default variant uses size=sm for a standalone surface", () => {
const html = renderToStaticMarkup(<ResumeRunButton runId="r-1" variant="default" />);
expect(html).toMatch(/data-size="sm"/);
});
});

View File

@ -0,0 +1,54 @@
"use client";
import { useTransition, useState } from "react";
import { Loader2Icon, PlayIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { resumeReminderRunAction } from "@/actions/reminders";
interface ResumeRunButtonProps {
runId: string;
/** Style hint "compact" suits inline rows, "default" suits the
* paused-detail banner which renders its own size already. */
variant?: "compact" | "default";
}
/**
* Small wrapper around resumeReminderRunAction so paused rows in the
* Activity tab can offer "Resume" without each row rolling its own
* useTransition / error handling. Cancel uses the detail banner
* it's the rarer path.
*/
export function ResumeRunButton({ runId, variant = "compact" }: ResumeRunButtonProps) {
const [pending, start] = useTransition();
const [error, setError] = useState<string | null>(null);
const onClick = () =>
start(async () => {
setError(null);
const r = await resumeReminderRunAction({ runId });
if (!r.ok) setError(r.error);
});
return (
<div className="inline-flex flex-col items-end gap-0.5">
<Button
type="button"
size={variant === "compact" ? "icon-sm" : "sm"}
variant="ghost"
onClick={onClick}
disabled={pending}
aria-label="Resume run"
className="text-emerald-700 hover:text-emerald-800 dark:text-emerald-400 dark:hover:text-emerald-300"
>
{pending ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<PlayIcon className="size-4" />
)}
</Button>
{error && (
<span className="text-[10px] text-destructive whitespace-nowrap">{error}</span>
)}
</div>
);
}

View File

@ -0,0 +1,71 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@/actions/reminders", () => ({
resumeReminderRunAction: vi.fn(),
cancelReminderRunAction: vi.fn(),
}));
import { PausedRunBanner } from "./paused-run-banner";
describe("PausedRunBanner — SSR layout", () => {
it("renders Resume + Cancel buttons inside the banner", () => {
const html = renderToStaticMarkup(
<PausedRunBanner
runId="r-1"
sent={412}
total={1000}
windowEndHour={18}
timezone="Asia/Kuala_Lumpur"
/>,
);
expect(html).toContain('data-testid="paused-run-banner"');
expect(html).toContain('data-testid="paused-resume"');
expect(html).toContain('data-testid="paused-cancel"');
expect(html).toMatch(/Resume<\/button>/);
expect(html).toMatch(/Cancel run<\/button>/);
});
it("shows X of Y groups delivered when sent + total are present", () => {
const html = renderToStaticMarkup(
<PausedRunBanner
runId="r-1"
sent={412}
total={1000}
windowEndHour={18}
timezone="Asia/Kuala_Lumpur"
/>,
);
expect(html).toContain("412 of 1000 groups delivered");
// Surfaces the window-end deadline so the operator knows why.
expect(html).toContain("18:00 (Asia/Kuala_Lumpur)");
// And the remaining count drives the CTA copy.
expect(html).toContain("send the remaining 588");
});
it("falls back to a generic body when sent / total aren't supplied", () => {
const html = renderToStaticMarkup(
<PausedRunBanner
runId="r-1"
windowEndHour={18}
timezone="Asia/Kuala_Lumpur"
/>,
);
expect(html).toMatch(/delivery window closed before/i);
expect(html).not.toContain("groups delivered");
});
it("uses amber styling so the banner reads as 'attention, not error'", () => {
const html = renderToStaticMarkup(
<PausedRunBanner
runId="r-1"
sent={1}
total={2}
windowEndHour={18}
timezone="UTC"
/>,
);
expect(html).toMatch(/border-amber-500/);
expect(html).toMatch(/bg-amber-500/);
});
});

View File

@ -0,0 +1,118 @@
"use client";
import { useState, useTransition } from "react";
import {
AlertCircleIcon,
PlayIcon,
XIcon,
Loader2Icon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
resumeReminderRunAction,
cancelReminderRunAction,
} from "@/actions/reminders";
interface PausedRunBannerProps {
runId: string;
/** Best-effort sent count for the body copy. Falls back to a
* generic message when undefined. */
sent?: number;
/** Best-effort total target count. */
total?: number;
/** Deadline hour the bot stopped at. Shown in the body copy. */
windowEndHour: number;
/** Operator timezone (for the deadline label). */
timezone: string;
}
/**
* Amber callout shown above the reminder detail view when the most
* recent run is in 'paused' state. Two interactive choices:
* Resume re-enqueues the run via the bot.
* Cancel run stops the run cleanly (remaining pending skipped).
*
* Pause notifications deep-link the operator into this surface.
*/
export function PausedRunBanner({
runId,
sent,
total,
windowEndHour,
timezone,
}: PausedRunBannerProps) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const onResume = () =>
startTransition(async () => {
setError(null);
const r = await resumeReminderRunAction({ runId });
if (!r.ok) setError(r.error);
});
const onCancel = () =>
startTransition(async () => {
setError(null);
const r = await cancelReminderRunAction({ runId });
if (!r.ok) setError(r.error);
});
const remaining =
typeof sent === "number" && typeof total === "number"
? Math.max(0, total - sent)
: null;
return (
<div
className="rounded-lg border border-amber-500/40 bg-amber-500/5 p-4 space-y-3"
data-testid="paused-run-banner"
>
<div className="flex items-start gap-2">
<AlertCircleIcon className="size-4 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
<div className="space-y-1 text-sm">
<p className="font-medium">Reminder paused</p>
<p className="text-xs text-muted-foreground">
{typeof sent === "number" && typeof total === "number"
? `${sent} of ${total} groups delivered.`
: "The delivery window closed before all groups got the message."}{" "}
The deadline was {windowEndHour}:00 ({timezone}).{" "}
{remaining !== null && remaining > 0
? `Resume to send the remaining ${remaining}, or cancel the run.`
: "Resume to keep going, or cancel the run."}
</p>
</div>
</div>
{error && (
<div className="text-xs text-destructive">{error}</div>
)}
<div className="flex gap-2">
<Button
size="sm"
onClick={onResume}
disabled={pending}
className="gap-2"
data-testid="paused-resume"
>
{pending ? (
<Loader2Icon className="size-3.5 animate-spin" />
) : (
<PlayIcon className="size-3.5" />
)}
Resume
</Button>
<Button
size="sm"
variant="outline"
onClick={onCancel}
disabled={pending}
className="gap-2"
data-testid="paused-cancel"
>
<XIcon className="size-3.5" />
Cancel run
</Button>
</div>
</div>
);
}

View File

@ -241,11 +241,23 @@ export async function getReminderWithRuns(operatorId: string, reminderId: string
where: (m, { eq }) => eq(m.reminderId, reminderId), where: (m, { eq }) => eq(m.reminderId, reminderId),
orderBy: (m, { asc }) => [asc(m.position)], orderBy: (m, { asc }) => [asc(m.position)],
}); });
// LEFT-JOIN aggregate counts in one round-trip so the detail page
// can render the paused banner with "X of Y groups delivered"
// without a per-run fan-out query. Counts are bigint in PG → cast
// to int so JSON marshalling stays lossless.
const runs = await db.execute(sql` const runs = await db.execute(sql`
SELECT id, fired_at, status, error_summary SELECT
FROM reminder_runs rr.id,
WHERE reminder_id = ${reminderId} rr.fired_at,
ORDER BY fired_at DESC rr.status,
rr.error_summary,
COALESCE(SUM(CASE WHEN rt.status = 'sent' THEN 1 ELSE 0 END)::int, 0) AS sent,
COALESCE(COUNT(rt.id)::int, 0) AS total
FROM reminder_runs rr
LEFT JOIN reminder_run_targets rt ON rt.run_id = rr.id
WHERE rr.reminder_id = ${reminderId}
GROUP BY rr.id, rr.fired_at, rr.status, rr.error_summary
ORDER BY rr.fired_at DESC
LIMIT 20 LIMIT 20
`); `);
return { return {
@ -261,6 +273,8 @@ export async function getReminderWithRuns(operatorId: string, reminderId: string
firedAt: r.fired_at as Date, firedAt: r.fired_at as Date,
status: r.status as string, status: r.status as string,
errorSummary: r.error_summary as string | null, errorSummary: r.error_summary as string | null,
sent: r.sent as number,
total: r.total as number,
})), })),
}; };
} }