yiekheng bb8d28a594 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>
2026-05-10 15:58:06 +08:00

119 lines
3.4 KiB
TypeScript

"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>
);
}