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 { 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" />
|
||||||
|
|||||||
@ -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