From e38b9ac7b63bd3d59863f180b8f8655ed941686d Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sun, 10 May 2026 15:11:53 +0800 Subject: [PATCH] feat(web): RunEtaPill at the wizard review step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders an advisory ETA badge above the Schedule button: * Green "Fits before deadline" when the projected finish lands before the chosen deadline hour. * Amber "Likely to pause" with a "Push the deadline later or split into smaller runs" hint when it doesn't. Pill is purely informational — the operator can still schedule a run that's likely to pause; the pause/resume flow (Phase 3) covers that case. The pill just removes the surprise. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../reminder-wizard/review-submit-client.tsx | 14 +++++ .../reminder-wizard/run-eta-pill.test.tsx | 46 ++++++++++++++ .../reminder-wizard/run-eta-pill.tsx | 62 +++++++++++++++++++ 3 files changed, 122 insertions(+) create mode 100644 apps/web/src/components/reminder-wizard/run-eta-pill.test.tsx create mode 100644 apps/web/src/components/reminder-wizard/run-eta-pill.tsx diff --git a/apps/web/src/components/reminder-wizard/review-submit-client.tsx b/apps/web/src/components/reminder-wizard/review-submit-client.tsx index ea3f762..649bed0 100644 --- a/apps/web/src/components/reminder-wizard/review-submit-client.tsx +++ b/apps/web/src/components/reminder-wizard/review-submit-client.tsx @@ -7,6 +7,8 @@ import { Button } from "@/components/ui/button"; import { createReminderAction, updateReminderAction } from "@/actions/reminders"; import { cn } from "@/lib/utils"; import type { MessagePart } from "@/lib/reminder-messages"; +import { RunEtaPill } from "./run-eta-pill"; +import { windowEndAt } from "@cmbot/shared"; interface ReviewSubmitClientProps { accountId: string; @@ -76,8 +78,20 @@ export function ReviewSubmitClient({ } } + const groupCount = groupIds ? groupIds.split(",").filter(Boolean).length : 0; + const fireAt = new Date(scheduledAt); + const endHour = deliveryEndHour ?? 18; + const wEnd = windowEndAt(timezone, endHour, fireAt); + return (
+ + {error && (
diff --git a/apps/web/src/components/reminder-wizard/run-eta-pill.test.tsx b/apps/web/src/components/reminder-wizard/run-eta-pill.test.tsx new file mode 100644 index 0000000..5c9ab79 --- /dev/null +++ b/apps/web/src/components/reminder-wizard/run-eta-pill.test.tsx @@ -0,0 +1,46 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { RunEtaPill } from "./run-eta-pill"; + +describe("RunEtaPill", () => { + it("renders nothing for zero targets", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toBe(""); + }); + + it("shows green 'Fits before deadline' when ETA finishes within the window", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toMatch(/Fits before deadline/); + expect(html).toMatch(/min/); + expect(html).not.toMatch(/Likely to pause/); + expect(html).toContain("emerald"); + }); + + it("shows amber 'Likely to pause' when ETA exceeds the window", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toMatch(/Likely to pause/); + expect(html).toMatch(/Push the deadline later/); + expect(html).toContain("amber"); + }); +}); diff --git a/apps/web/src/components/reminder-wizard/run-eta-pill.tsx b/apps/web/src/components/reminder-wizard/run-eta-pill.tsx new file mode 100644 index 0000000..11e486a --- /dev/null +++ b/apps/web/src/components/reminder-wizard/run-eta-pill.tsx @@ -0,0 +1,62 @@ +import { ClockIcon, AlertTriangleIcon } from "lucide-react"; +import { estimateRunDuration } from "@/lib/run-eta"; + +interface RunEtaPillProps { + targetCount: number; + fireAt: Date; + windowEndAt: Date; + timezone: string; +} + +/** + * Visible at the wizard's review step and on the per-section edit + * pages that change ETA inputs (groups, when). Advisory only — does + * NOT block submission. The operator can still schedule a run that's + * likely to pause; pause-and-resume covers that case. The pill just + * removes the surprise. + */ +export function RunEtaPill({ + targetCount, + fireAt, + windowEndAt, + timezone, +}: RunEtaPillProps) { + if (targetCount <= 0) return null; + + const { durationMinutes, estimatedFinishAt } = estimateRunDuration({ + targetCount, + fireAt, + }); + const fits = estimatedFinishAt.getTime() <= windowEndAt.getTime(); + + const finishLocal = new Intl.DateTimeFormat("en-GB", { + hour: "numeric", + minute: "2-digit", + hour12: true, + timeZone: timezone, + }).format(estimatedFinishAt); + + if (fits) { + return ( +
+ + + ~{durationMinutes} min · finishes ~{finishLocal} · Fits before deadline + +
+ ); + } + return ( +
+ +
+
+ ~{durationMinutes} min · finishes ~{finishLocal} · Likely to pause +
+
+ Push the deadline later or split into smaller runs. +
+
+
+ ); +}