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:
parent
376bbe595b
commit
bb8d28a594
@ -6,6 +6,8 @@ import {
|
||||
ArchiveRestoreIcon,
|
||||
CheckCircle2Icon,
|
||||
MinusCircleIcon,
|
||||
PauseCircleIcon,
|
||||
PlayIcon,
|
||||
Trash2Icon,
|
||||
XCircleIcon,
|
||||
} from "lucide-react";
|
||||
@ -41,6 +43,7 @@ import {
|
||||
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;
|
||||
@ -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",
|
||||
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:
|
||||
@ -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 }[] = [
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "success", label: "Success" },
|
||||
{ value: "paused", label: "Paused" },
|
||||
{ value: "partial", label: "Partial" },
|
||||
{ value: "failed", label: "Failed" },
|
||||
{ value: "skipped", label: "Skipped" },
|
||||
@ -167,6 +184,7 @@ 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" ||
|
||||
@ -354,6 +372,9 @@ export default async function ActivityPage({ searchParams }: PageProps) {
|
||||
</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
|
||||
|
||||
@ -30,6 +30,7 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { getSeededOperator } from "@/lib/operator";
|
||||
import { getReminderWithRuns } from "@/lib/queries";
|
||||
import { PausedRunBanner } from "@/components/reminder-detail/paused-run-banner";
|
||||
import { ActionsBar } from "./actions-bar";
|
||||
|
||||
function formatWhen(date: Date | null, tz: string): string {
|
||||
@ -119,6 +120,22 @@ export default async function ReminderDetailPage({ params }: Props) {
|
||||
</p>
|
||||
</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 />
|
||||
|
||||
{/* Name — click to edit. Required field, the operator's
|
||||
|
||||
32
apps/web/src/components/activity/resume-run-button.test.tsx
Normal file
32
apps/web/src/components/activity/resume-run-button.test.tsx
Normal 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"/);
|
||||
});
|
||||
});
|
||||
54
apps/web/src/components/activity/resume-run-button.tsx
Normal file
54
apps/web/src/components/activity/resume-run-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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/);
|
||||
});
|
||||
});
|
||||
118
apps/web/src/components/reminder-detail/paused-run-banner.tsx
Normal file
118
apps/web/src/components/reminder-detail/paused-run-banner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -241,11 +241,23 @@ export async function getReminderWithRuns(operatorId: string, reminderId: string
|
||||
where: (m, { eq }) => eq(m.reminderId, reminderId),
|
||||
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`
|
||||
SELECT id, fired_at, status, error_summary
|
||||
FROM reminder_runs
|
||||
WHERE reminder_id = ${reminderId}
|
||||
ORDER BY fired_at DESC
|
||||
SELECT
|
||||
rr.id,
|
||||
rr.fired_at,
|
||||
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
|
||||
`);
|
||||
return {
|
||||
@ -261,6 +273,8 @@ export async function getReminderWithRuns(operatorId: string, reminderId: string
|
||||
firedAt: r.fired_at as Date,
|
||||
status: r.status as string,
|
||||
errorSummary: r.error_summary as string | null,
|
||||
sent: r.sent as number,
|
||||
total: r.total as number,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user