feat(web): "Pause sending by" deadline is opt-in via a checkbox
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>
This commit is contained in:
parent
309020fa5d
commit
2e1defaef6
@ -52,7 +52,15 @@ export function EditWhenForm({
|
||||
const [date, setDate] = useState(initial.date);
|
||||
const [time, setTime] = useState(initial.time);
|
||||
const [spec, setSpec] = useState<RecurrenceSpec>(initialSpec);
|
||||
const [deliveryEndHour, setDeliveryEndHour] = useState<number>(initialDeliveryEndHour);
|
||||
// Optional deadline: 24 (next-day midnight) is the off-sentinel —
|
||||
// hour=24 makes windowEndAt return tomorrow's start, effectively
|
||||
// "no deadline today". Existing rows at 24 land with the toggle
|
||||
// OFF; rows at any other value land toggled ON with that value.
|
||||
const initialUseDeadline = initialDeliveryEndHour !== 24;
|
||||
const [useDeadline, setUseDeadline] = useState<boolean>(initialUseDeadline);
|
||||
const [deliveryEndHour, setDeliveryEndHour] = useState<number>(
|
||||
initialUseDeadline ? initialDeliveryEndHour : 18,
|
||||
);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@ -108,7 +116,7 @@ export function EditWhenForm({
|
||||
scheduledAtIso,
|
||||
rrule,
|
||||
timezone,
|
||||
deliveryWindowEndHour: deliveryEndHour,
|
||||
deliveryWindowEndHour: useDeadline ? deliveryEndHour : 24,
|
||||
});
|
||||
if (r.ok) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@ -163,12 +171,25 @@ export function EditWhenForm({
|
||||
<RecurrencePicker firstFire={previewDt} value={spec} onChange={setSpec} />
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="flex items-center gap-1.5">
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useDeadline}
|
||||
onChange={(e) => {
|
||||
setUseDeadline(e.target.checked);
|
||||
setError(null);
|
||||
}}
|
||||
className="size-4 rounded border-input accent-primary"
|
||||
aria-label="Set a delivery deadline"
|
||||
/>
|
||||
<span className="text-sm font-medium flex items-center gap-1.5">
|
||||
<ClockIcon className="size-3.5" />
|
||||
Pause sending by
|
||||
<span className="text-xs font-normal text-muted-foreground">(optional)</span>
|
||||
</Label>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
</span>
|
||||
</label>
|
||||
{useDeadline && (
|
||||
<div className="flex flex-wrap items-center gap-2 pl-6">
|
||||
<HourSelect
|
||||
ariaPrefix="Delivery deadline"
|
||||
value={deliveryEndHour}
|
||||
@ -179,6 +200,7 @@ export function EditWhenForm({
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">({timezone})</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
|
||||
@ -82,8 +82,12 @@ 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);
|
||||
// Treat hour=24 (or unset) as "no deadline". The pill goes neutral.
|
||||
const hasDeadline =
|
||||
deliveryEndHour !== undefined && deliveryEndHour !== 24;
|
||||
const wEnd = hasDeadline
|
||||
? windowEndAt(timezone, deliveryEndHour!, fireAt)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="space-y-3 pt-2">
|
||||
|
||||
@ -3,6 +3,20 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { RunEtaPill } from "./run-eta-pill";
|
||||
|
||||
describe("RunEtaPill", () => {
|
||||
it("renders a neutral ETA when windowEndAt is omitted (no deadline)", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<RunEtaPill
|
||||
targetCount={500}
|
||||
fireAt={new Date("2026-05-13T09:00:00.000+08:00")}
|
||||
timezone="Asia/Kuala_Lumpur"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-testid="eta-pill-neutral"');
|
||||
expect(html).toMatch(/min/);
|
||||
expect(html).not.toMatch(/Fits before deadline/);
|
||||
expect(html).not.toMatch(/Likely to pause/);
|
||||
});
|
||||
|
||||
it("renders nothing for zero targets", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<RunEtaPill
|
||||
|
||||
@ -4,7 +4,10 @@ import { estimateRunDuration } from "@/lib/run-eta";
|
||||
interface RunEtaPillProps {
|
||||
targetCount: number;
|
||||
fireAt: Date;
|
||||
windowEndAt: 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;
|
||||
}
|
||||
|
||||
@ -27,8 +30,6 @@ export function RunEtaPill({
|
||||
targetCount,
|
||||
fireAt,
|
||||
});
|
||||
const fits = estimatedFinishAt.getTime() <= windowEndAt.getTime();
|
||||
|
||||
const finishLocal = new Intl.DateTimeFormat("en-GB", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
@ -36,6 +37,23 @@ export function RunEtaPill({
|
||||
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">
|
||||
|
||||
@ -45,8 +45,16 @@ export function WhenFormClient({
|
||||
const [date, setDate] = useState(initial.date);
|
||||
const [time, setTime] = useState(initial.time);
|
||||
const [spec, setSpec] = useState<RecurrenceSpec>(initialSpec ?? DEFAULT_RECURRENCE);
|
||||
// Deadline is optional. We model it as two states: a checkbox that
|
||||
// turns it on/off, and the picked hour (only meaningful when the
|
||||
// checkbox is on). 24 (next-day midnight) is the off-sentinel sent
|
||||
// to the server — windowEndAt treats it as "end of today" so the
|
||||
// bot's window-end gate effectively never trips for short runs.
|
||||
const initialUseDeadline =
|
||||
initialDeliveryEndHour !== undefined && initialDeliveryEndHour !== 24;
|
||||
const [useDeadline, setUseDeadline] = useState<boolean>(initialUseDeadline);
|
||||
const [deliveryEndHour, setDeliveryEndHour] = useState<number>(
|
||||
initialDeliveryEndHour ?? 18,
|
||||
initialUseDeadline ? (initialDeliveryEndHour ?? 18) : 18,
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@ -77,7 +85,8 @@ export function WhenFormClient({
|
||||
if (passThroughParams.name) sp.set("name", passThroughParams.name);
|
||||
if (passThroughParams.messages) sp.set("messages", passThroughParams.messages);
|
||||
if (passThroughParams.editReminderId) sp.set("editReminderId", passThroughParams.editReminderId);
|
||||
sp.set("deliveryEndHour", String(deliveryEndHour));
|
||||
// 24 = "no deadline" sentinel (windowEndAt → next-day midnight).
|
||||
sp.set("deliveryEndHour", String(useDeadline ? deliveryEndHour : 24));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
router.push(`/reminders/new?${sp.toString()}` as any);
|
||||
return;
|
||||
@ -121,7 +130,8 @@ export function WhenFormClient({
|
||||
if (passThroughParams.name) sp.set("name", passThroughParams.name);
|
||||
if (passThroughParams.messages) sp.set("messages", passThroughParams.messages);
|
||||
if (passThroughParams.editReminderId) sp.set("editReminderId", passThroughParams.editReminderId);
|
||||
sp.set("deliveryEndHour", String(deliveryEndHour));
|
||||
// 24 = "no deadline" sentinel (windowEndAt → next-day midnight).
|
||||
sp.set("deliveryEndHour", String(useDeadline ? deliveryEndHour : 24));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
router.push(`/reminders/new?${sp.toString()}` as any);
|
||||
}
|
||||
@ -168,14 +178,29 @@ export function WhenFormClient({
|
||||
|
||||
{/* Deadline — fire time is the implicit start; this only sets when
|
||||
the bot must stop. Long fan-outs that don't finish before the
|
||||
deadline are paused so the operator can resume them later. */}
|
||||
deadline are paused so the operator can resume them later.
|
||||
The whole control is opt-in: tick the box to surface the hour
|
||||
picker, untick to remove the deadline entirely. */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="flex items-center gap-1.5">
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useDeadline}
|
||||
onChange={(e) => {
|
||||
setUseDeadline(e.target.checked);
|
||||
setError(null);
|
||||
}}
|
||||
className="size-4 rounded border-input accent-primary"
|
||||
aria-label="Set a delivery deadline"
|
||||
/>
|
||||
<span className="text-sm font-medium flex items-center gap-1.5">
|
||||
<ClockIcon className="size-3.5" />
|
||||
Pause sending by
|
||||
<span className="text-xs font-normal text-muted-foreground">(optional)</span>
|
||||
</Label>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
</span>
|
||||
</label>
|
||||
{useDeadline && (
|
||||
<div className="flex flex-wrap items-center gap-2 pl-6">
|
||||
<HourSelect
|
||||
ariaPrefix="Delivery deadline"
|
||||
value={deliveryEndHour}
|
||||
@ -186,6 +211,7 @@ export function WhenFormClient({
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">({timezone})</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
|
||||
@ -0,0 +1,86 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
// next/navigation is touched by useRouter — stub it.
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}));
|
||||
|
||||
// next/link → transparent <a> so the markup we assert on stays simple.
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({
|
||||
href,
|
||||
children,
|
||||
...rest
|
||||
}: { href: string; children: ReactNode } & Record<string, unknown>) => (
|
||||
<a href={href} {...rest}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
import { WhenFormClient } from "./when-form-client";
|
||||
|
||||
const baseProps = {
|
||||
accountId: "acc-1",
|
||||
groupIds: "g-1",
|
||||
timezone: "Asia/Kuala_Lumpur",
|
||||
initialDefaultIso: "2026-05-13T09:00:00.000+08:00",
|
||||
passThroughParams: { name: "test", messages: "x" },
|
||||
};
|
||||
|
||||
/**
|
||||
* The "Pause sending by" deadline is opt-in. The checkbox controls
|
||||
* whether the HourSelect is rendered at all; when off, the form
|
||||
* sends 24 (next-day midnight) to the server, which makes the bot's
|
||||
* window-end gate effectively never trip. These tests lock in the
|
||||
* SSR markup for the three states (off by default, off when the
|
||||
* stored value is 24, on when the stored value is something else).
|
||||
*/
|
||||
describe("WhenFormClient — deadline checkbox", () => {
|
||||
it("defaults to UNCHECKED for a fresh reminder (no initialDeliveryEndHour)", () => {
|
||||
const html = renderToStaticMarkup(<WhenFormClient {...baseProps} />);
|
||||
// Checkbox is rendered but not checked.
|
||||
expect(html).toMatch(
|
||||
/<input[^>]*type="checkbox"[^>]*aria-label="Set a delivery deadline"[^>]*>/,
|
||||
);
|
||||
expect(html).not.toMatch(
|
||||
/<input[^>]*type="checkbox"[^>]*aria-label="Set a delivery deadline"[^>]*checked/,
|
||||
);
|
||||
// No HourSelect rendered while the box is unchecked.
|
||||
expect(html).not.toMatch(/aria-label="Delivery deadline hour"/);
|
||||
});
|
||||
|
||||
it("starts UNCHECKED when initialDeliveryEndHour is 24 (the off-sentinel)", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<WhenFormClient {...baseProps} initialDeliveryEndHour={24} />,
|
||||
);
|
||||
expect(html).not.toMatch(
|
||||
/<input[^>]*type="checkbox"[^>]*aria-label="Set a delivery deadline"[^>]*checked/,
|
||||
);
|
||||
expect(html).not.toMatch(/aria-label="Delivery deadline hour"/);
|
||||
});
|
||||
|
||||
it("starts CHECKED + reveals the hour picker when initialDeliveryEndHour is set to a real hour", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<WhenFormClient {...baseProps} initialDeliveryEndHour={18} />,
|
||||
);
|
||||
// Checkbox is checked.
|
||||
expect(html).toMatch(
|
||||
/<input[^>]*type="checkbox"[^>]*aria-label="Set a delivery deadline"[^>]*checked/,
|
||||
);
|
||||
// The hour + period selects render under the checkbox.
|
||||
expect(html).toMatch(/aria-label="Delivery deadline hour"/);
|
||||
expect(html).toMatch(/aria-label="Delivery deadline period"/);
|
||||
// Pre-selected hour matches the initial value (18 → 6 PM).
|
||||
expect(html).toMatch(/value="6"\s+selected/);
|
||||
expect(html).toMatch(/value="PM"\s+selected/);
|
||||
});
|
||||
|
||||
it("offers a clear (optional) hint next to the label", () => {
|
||||
const html = renderToStaticMarkup(<WhenFormClient {...baseProps} />);
|
||||
expect(html).toContain("Pause sending by");
|
||||
expect(html).toContain("(optional)");
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user