Wizard When-step and the per-section Edit-when page now gate the
HourSelect behind a checkbox. The control reads "[ ] Pause sending
by (optional)" by default — checking it reveals the hour picker;
unchecking hides it again.
The off-state is encoded as deliveryWindowEndHour=24 (next-day
midnight) so the bot's existing windowEndAt helper produces an end
that's always in the future for any reminder fired the same day,
making the gate effectively never trip. This avoids a NULL-allowing
schema migration while still giving the operator a clean "no
deadline" mode.
Existing reminders:
• Stored 24 → checkbox starts UNCHECKED, picker hidden.
• Stored anything else → checkbox starts CHECKED, picker shows
the saved value.
• Unsupplied (legacy rows) → checkbox starts UNCHECKED.
RunEtaPill picks up an optional `windowEndAt` prop. When omitted —
the no-deadline path — it renders a neutral grey pill with just the
ETA, skipping the green "Fits before deadline" / amber "Likely to
pause" comparison that wouldn't be meaningful without a deadline.
Tests:
* when-form-deadline.test.tsx (4) — fresh / 24 / real-hour /
optional-hint paths.
* run-eta-pill.test.tsx (+1) — neutral pill when windowEndAt is
undefined.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
81 lines
2.4 KiB
TypeScript
81 lines
2.4 KiB
TypeScript
import { ClockIcon, AlertTriangleIcon } from "lucide-react";
|
|
import { estimateRunDuration } from "@/lib/run-eta";
|
|
|
|
interface RunEtaPillProps {
|
|
targetCount: number;
|
|
fireAt: Date;
|
|
/** Optional. When omitted (or when the operator picked "no
|
|
* deadline"), the pill renders a neutral ETA without the
|
|
* green/amber fit indicator. */
|
|
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 finishLocal = new Intl.DateTimeFormat("en-GB", {
|
|
hour: "numeric",
|
|
minute: "2-digit",
|
|
hour12: true,
|
|
timeZone: timezone,
|
|
}).format(estimatedFinishAt);
|
|
|
|
// No deadline → neutral ETA, no green/amber comparison.
|
|
if (!windowEndAt) {
|
|
return (
|
|
<div
|
|
className="flex items-center gap-2 rounded-lg bg-muted px-3 py-2 text-xs text-muted-foreground"
|
|
data-testid="eta-pill-neutral"
|
|
>
|
|
<ClockIcon className="size-3.5" />
|
|
<span>
|
|
~{durationMinutes} min · finishes ~{finishLocal}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const fits = estimatedFinishAt.getTime() <= windowEndAt.getTime();
|
|
|
|
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>
|
|
);
|
|
}
|