feat(web): RunEtaPill at the wizard review step

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) <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-05-10 15:11:53 +08:00
parent e6521bd151
commit e38b9ac7b6
3 changed files with 122 additions and 0 deletions

View File

@ -7,6 +7,8 @@ import { Button } from "@/components/ui/button";
import { createReminderAction, updateReminderAction } from "@/actions/reminders"; import { createReminderAction, updateReminderAction } from "@/actions/reminders";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { MessagePart } from "@/lib/reminder-messages"; import type { MessagePart } from "@/lib/reminder-messages";
import { RunEtaPill } from "./run-eta-pill";
import { windowEndAt } from "@cmbot/shared";
interface ReviewSubmitClientProps { interface ReviewSubmitClientProps {
accountId: string; 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 ( return (
<div className="space-y-3 pt-2"> <div className="space-y-3 pt-2">
<RunEtaPill
targetCount={groupCount}
fireAt={fireAt}
windowEndAt={wEnd}
timezone={timezone}
/>
{error && ( {error && (
<div className="flex items-start gap-2 rounded-lg bg-destructive/10 px-3 py-2.5 text-sm text-destructive"> <div className="flex items-start gap-2 rounded-lg bg-destructive/10 px-3 py-2.5 text-sm text-destructive">
<AlertCircleIcon className="size-4 shrink-0 mt-0.5" /> <AlertCircleIcon className="size-4 shrink-0 mt-0.5" />

View File

@ -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(
<RunEtaPill
targetCount={0}
fireAt={new Date("2026-05-13T09:00:00.000+08:00")}
windowEndAt={new Date("2026-05-13T18:00:00.000+08:00")}
timezone="Asia/Kuala_Lumpur"
/>,
);
expect(html).toBe("");
});
it("shows green 'Fits before deadline' when ETA finishes within the window", () => {
const html = renderToStaticMarkup(
<RunEtaPill
targetCount={500}
fireAt={new Date("2026-05-13T09:00:00.000+08:00")}
windowEndAt={new Date("2026-05-13T18:00:00.000+08:00")}
timezone="Asia/Kuala_Lumpur"
/>,
);
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(
<RunEtaPill
targetCount={5000}
fireAt={new Date("2026-05-13T17:00:00.000+08:00")}
windowEndAt={new Date("2026-05-13T18:00:00.000+08:00")}
timezone="Asia/Kuala_Lumpur"
/>,
);
expect(html).toMatch(/Likely to pause/);
expect(html).toMatch(/Push the deadline later/);
expect(html).toContain("amber");
});
});

View File

@ -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 (
<div className="flex items-center gap-2 rounded-lg bg-emerald-500/10 px-3 py-2 text-xs text-emerald-700 dark:text-emerald-400">
<ClockIcon className="size-3.5" />
<span>
~{durationMinutes} min · finishes ~{finishLocal} · Fits before deadline
</span>
</div>
);
}
return (
<div className="flex items-start gap-2 rounded-lg bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-400">
<AlertTriangleIcon className="size-3.5 mt-0.5 shrink-0" />
<div className="space-y-0.5">
<div>
~{durationMinutes} min · finishes ~{finishLocal} · Likely to pause
</div>
<div className="text-[11px] opacity-80">
Push the deadline later or split into smaller runs.
</div>
</div>
</div>
);
}