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,
|
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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
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),
|
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,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user