From 2e1defaef6b6e6cdcc067c13a8ba02397f835116 Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sun, 10 May 2026 16:25:44 +0800 Subject: [PATCH] feat(web): "Pause sending by" deadline is opt-in via a checkbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../reminder-edit/edit-when-form.tsx | 52 +++++++---- .../reminder-wizard/review-submit-client.tsx | 8 +- .../reminder-wizard/run-eta-pill.test.tsx | 14 +++ .../reminder-wizard/run-eta-pill.tsx | 24 +++++- .../reminder-wizard/when-form-client.tsx | 60 +++++++++---- .../when-form-deadline.test.tsx | 86 +++++++++++++++++++ 6 files changed, 207 insertions(+), 37 deletions(-) create mode 100644 apps/web/src/components/reminder-wizard/when-form-deadline.test.tsx diff --git a/apps/web/src/components/reminder-edit/edit-when-form.tsx b/apps/web/src/components/reminder-edit/edit-when-form.tsx index cfa8a0c..c523adc 100644 --- a/apps/web/src/components/reminder-edit/edit-when-form.tsx +++ b/apps/web/src/components/reminder-edit/edit-when-form.tsx @@ -52,7 +52,15 @@ export function EditWhenForm({ const [date, setDate] = useState(initial.date); const [time, setTime] = useState(initial.time); const [spec, setSpec] = useState(initialSpec); - const [deliveryEndHour, setDeliveryEndHour] = useState(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(initialUseDeadline); + const [deliveryEndHour, setDeliveryEndHour] = useState( + initialUseDeadline ? initialDeliveryEndHour : 18, + ); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(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,22 +171,36 @@ export function EditWhenForm({
- -
- { - setDeliveryEndHour(h); +
+ + + Pause sending by + (optional) + + + {useDeadline && ( +
+ { + setDeliveryEndHour(h); + setError(null); + }} + /> + ({timezone}) +
+ )}
{error && ( diff --git a/apps/web/src/components/reminder-wizard/review-submit-client.tsx b/apps/web/src/components/reminder-wizard/review-submit-client.tsx index 1be7f5b..34150db 100644 --- a/apps/web/src/components/reminder-wizard/review-submit-client.tsx +++ b/apps/web/src/components/reminder-wizard/review-submit-client.tsx @@ -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 (
diff --git a/apps/web/src/components/reminder-wizard/run-eta-pill.test.tsx b/apps/web/src/components/reminder-wizard/run-eta-pill.test.tsx index 5c9ab79..3710a0e 100644 --- a/apps/web/src/components/reminder-wizard/run-eta-pill.test.tsx +++ b/apps/web/src/components/reminder-wizard/run-eta-pill.test.tsx @@ -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( + , + ); + 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( + + + ~{durationMinutes} min · finishes ~{finishLocal} + +
+ ); + } + + const fits = estimatedFinishAt.getTime() <= windowEndAt.getTime(); + if (fits) { return (
diff --git a/apps/web/src/components/reminder-wizard/when-form-client.tsx b/apps/web/src/components/reminder-wizard/when-form-client.tsx index 8860688..63ecafa 100644 --- a/apps/web/src/components/reminder-wizard/when-form-client.tsx +++ b/apps/web/src/components/reminder-wizard/when-form-client.tsx @@ -45,8 +45,16 @@ export function WhenFormClient({ const [date, setDate] = useState(initial.date); const [time, setTime] = useState(initial.time); const [spec, setSpec] = useState(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(initialUseDeadline); const [deliveryEndHour, setDeliveryEndHour] = useState( - initialDeliveryEndHour ?? 18, + initialUseDeadline ? (initialDeliveryEndHour ?? 18) : 18, ); const [error, setError] = useState(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,24 +178,40 @@ 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. */}
- -
- { - setDeliveryEndHour(h); +
+ + + Pause sending by + (optional) + + + {useDeadline && ( +
+ { + setDeliveryEndHour(h); + setError(null); + }} + /> + ({timezone}) +
+ )}
{error && ( diff --git a/apps/web/src/components/reminder-wizard/when-form-deadline.test.tsx b/apps/web/src/components/reminder-wizard/when-form-deadline.test.tsx new file mode 100644 index 0000000..584fe7f --- /dev/null +++ b/apps/web/src/components/reminder-wizard/when-form-deadline.test.tsx @@ -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 so the markup we assert on stays simple. +vi.mock("next/link", () => ({ + default: ({ + href, + children, + ...rest + }: { href: string; children: ReactNode } & Record) => ( + + {children} + + ), +})); + +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(); + // Checkbox is rendered but not checked. + expect(html).toMatch( + /]*type="checkbox"[^>]*aria-label="Set a delivery deadline"[^>]*>/, + ); + expect(html).not.toMatch( + /]*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( + , + ); + expect(html).not.toMatch( + /]*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( + , + ); + // Checkbox is checked. + expect(html).toMatch( + /]*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(); + expect(html).toContain("Pause sending by"); + expect(html).toContain("(optional)"); + }); +});