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)"); + }); +});