diff --git a/apps/web/src/app/activity/page.tsx b/apps/web/src/app/activity/page.tsx index 46e3b44..7018a85 100644 --- a/apps/web/src/app/activity/page.tsx +++ b/apps/web/src/app/activity/page.tsx @@ -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) {
+ {run.status === "paused" && ( + + )}
+ {/* 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 ( + + ); + })()} + {/* Name — click to edit. Required field, the operator's diff --git a/apps/web/src/components/activity/resume-run-button.test.tsx b/apps/web/src/components/activity/resume-run-button.test.tsx new file mode 100644 index 0000000..c6f5c2d --- /dev/null +++ b/apps/web/src/components/activity/resume-run-button.test.tsx @@ -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(); + 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(); + expect(html).toMatch(/text-emerald-700/); + }); + + it("compact variant uses size=icon-sm so it fits inline in the table", () => { + const html = renderToStaticMarkup(); + // 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(); + expect(html).toMatch(/data-size="sm"/); + }); +}); diff --git a/apps/web/src/components/activity/resume-run-button.tsx b/apps/web/src/components/activity/resume-run-button.tsx new file mode 100644 index 0000000..5a15bd5 --- /dev/null +++ b/apps/web/src/components/activity/resume-run-button.tsx @@ -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(null); + + const onClick = () => + start(async () => { + setError(null); + const r = await resumeReminderRunAction({ runId }); + if (!r.ok) setError(r.error); + }); + + return ( +
+ + {error && ( + {error} + )} +
+ ); +} diff --git a/apps/web/src/components/reminder-detail/paused-run-banner.test.tsx b/apps/web/src/components/reminder-detail/paused-run-banner.test.tsx new file mode 100644 index 0000000..ec400e7 --- /dev/null +++ b/apps/web/src/components/reminder-detail/paused-run-banner.test.tsx @@ -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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + expect(html).toMatch(/border-amber-500/); + expect(html).toMatch(/bg-amber-500/); + }); +}); diff --git a/apps/web/src/components/reminder-detail/paused-run-banner.tsx b/apps/web/src/components/reminder-detail/paused-run-banner.tsx new file mode 100644 index 0000000..617c7c6 --- /dev/null +++ b/apps/web/src/components/reminder-detail/paused-run-banner.tsx @@ -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(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 ( +
+
+ +
+

Reminder paused

+

+ {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."} +

+
+
+ {error && ( +
{error}
+ )} +
+ + +
+
+ ); +} diff --git a/apps/web/src/lib/queries.ts b/apps/web/src/lib/queries.ts index 74a5262..6bc9723 100644 --- a/apps/web/src/lib/queries.ts +++ b/apps/web/src/lib/queries.ts @@ -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, })), }; }