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:
parent
e6521bd151
commit
e38b9ac7b6
@ -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 (
|
||||
<div className="space-y-3 pt-2">
|
||||
<RunEtaPill
|
||||
targetCount={groupCount}
|
||||
fireAt={fireAt}
|
||||
windowEndAt={wEnd}
|
||||
timezone={timezone}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<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" />
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
62
apps/web/src/components/reminder-wizard/run-eta-pill.tsx
Normal file
62
apps/web/src/components/reminder-wizard/run-eta-pill.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user