From bf49b804316485813d0ba45015042b93c5d72d27 Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sun, 10 May 2026 15:07:25 +0800 Subject: [PATCH] feat(web): pause-by-hour deadline + AM/PM dropdowns + dashboard tweaks 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 expose an optional "Pause sending by" hour. Fire time IS the implicit start, so the deadline is the only thing the operator sets. When the bot's fan-out hasn't finished by that hour (in the reminder's timezone) the run pauses for resume — that runtime gating lands in a later phase; this commit just persists the hour and threads it through the wizard. HourSelect splits hour and AM/PM into two side-by-side menus: one picks the 12-hour value + * (1..12), the other picks AM/PM. They emit a single 0..23 value + * so callers don't have to think about the conversion. + */ +export function HourSelect({ value, onChange, ariaPrefix }: HourSelectProps) { + const { hour12, period } = to12Hour(value); + + return ( +
+ + +
+ ); +} 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 b50280b..cfa8a0c 100644 --- a/apps/web/src/components/reminder-edit/edit-when-form.tsx +++ b/apps/web/src/components/reminder-edit/edit-when-form.tsx @@ -16,6 +16,7 @@ import { Label } from "@/components/ui/label"; import { splitDateTime, validateScheduledAt } from "@/lib/date-picker"; import { buildRrule, type RecurrenceSpec } from "@/lib/recurrence"; import { RecurrencePicker } from "@/components/recurrence-picker"; +import { HourSelect } from "@/components/hour-select"; import { updateReminderAction } from "@/actions/reminders"; import type { MessagePart } from "@/lib/reminder-messages"; @@ -30,6 +31,7 @@ interface EditWhenFormProps { name: string; initialIso: string; initialSpec: RecurrenceSpec; + initialDeliveryEndHour: number; timezone: string; } @@ -41,6 +43,7 @@ export function EditWhenForm({ name, initialIso, initialSpec, + initialDeliveryEndHour, timezone, }: EditWhenFormProps) { const router = useRouter(); @@ -49,6 +52,7 @@ 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); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); @@ -104,6 +108,7 @@ export function EditWhenForm({ scheduledAtIso, rrule, timezone, + deliveryWindowEndHour: deliveryEndHour, }); if (r.ok) { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -157,6 +162,25 @@ export function EditWhenForm({ +
+ +
+ { + setDeliveryEndHour(h); + setError(null); + }} + /> + ({timezone}) +
+
+ {error && (
diff --git a/apps/web/src/components/reminder-wizard/groups-form-client.tsx b/apps/web/src/components/reminder-wizard/groups-form-client.tsx index 4244513..acd54cc 100644 --- a/apps/web/src/components/reminder-wizard/groups-form-client.tsx +++ b/apps/web/src/components/reminder-wizard/groups-form-client.tsx @@ -22,6 +22,7 @@ interface PassThroughParams { scheduledAt?: string; rrule?: string; editReminderId?: string; + deliveryEndHour?: string; } interface GroupsFormClientProps { @@ -74,6 +75,7 @@ export function GroupsFormClient({ if (passThroughParams.scheduledAt) sp.set("scheduledAt", passThroughParams.scheduledAt); if (passThroughParams.rrule) sp.set("rrule", passThroughParams.rrule); if (passThroughParams.editReminderId) sp.set("editReminderId", passThroughParams.editReminderId); + if (passThroughParams.deliveryEndHour) sp.set("deliveryEndHour", passThroughParams.deliveryEndHour); // eslint-disable-next-line @typescript-eslint/no-explicit-any router.push(`/reminders/new?${sp.toString()}` as any); } 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 8cd2c90..ea3f762 100644 --- a/apps/web/src/components/reminder-wizard/review-submit-client.tsx +++ b/apps/web/src/components/reminder-wizard/review-submit-client.tsx @@ -17,6 +17,7 @@ interface ReviewSubmitClientProps { rrule?: string; editReminderId?: string; timezone: string; + deliveryEndHour?: number; } export function ReviewSubmitClient({ @@ -28,6 +29,7 @@ export function ReviewSubmitClient({ rrule, editReminderId, timezone, + deliveryEndHour, }: ReviewSubmitClientProps) { const router = useRouter(); const [submitting, setSubmitting] = useState(false); @@ -55,6 +57,7 @@ export function ReviewSubmitClient({ scheduledAtIso: scheduledAt, rrule: rrule ?? null, timezone, + deliveryWindowEndHour: deliveryEndHour, }; const result = editReminderId ? await updateReminderAction({ ...payload, reminderId: editReminderId }) diff --git a/apps/web/src/components/reminder-wizard/step-groups.tsx b/apps/web/src/components/reminder-wizard/step-groups.tsx index a5978bd..ca2b390 100644 --- a/apps/web/src/components/reminder-wizard/step-groups.tsx +++ b/apps/web/src/components/reminder-wizard/step-groups.tsx @@ -20,6 +20,7 @@ interface StepGroupsParams { rrule?: string; groupId?: string; editReminderId?: string; + deliveryEndHour?: string; } interface StepGroupsProps { @@ -76,6 +77,7 @@ export async function StepGroups({ params }: StepGroupsProps) { if (scheduledAt) backParams.set("scheduledAt", scheduledAt); if (rrule) backParams.set("rrule", rrule); if (editReminderId) backParams.set("editReminderId", editReminderId); + if (params.deliveryEndHour) backParams.set("deliveryEndHour", params.deliveryEndHour); const backHref = `/reminders/new?${backParams.toString()}`; return ( @@ -99,7 +101,14 @@ export async function StepGroups({ params }: StepGroupsProps) { groups={groups} preSelected={preSelected} accountId={accountId} - passThroughParams={{ name: params.name, messages, scheduledAt, rrule, editReminderId }} + passThroughParams={{ + name: params.name, + messages, + scheduledAt, + rrule, + editReminderId, + deliveryEndHour: params.deliveryEndHour, + }} />
); diff --git a/apps/web/src/components/reminder-wizard/step-review.tsx b/apps/web/src/components/reminder-wizard/step-review.tsx index 7b64bd3..05e839d 100644 --- a/apps/web/src/components/reminder-wizard/step-review.tsx +++ b/apps/web/src/components/reminder-wizard/step-review.tsx @@ -38,6 +38,7 @@ interface StepReviewParams { scheduledAt?: string; rrule?: string; editReminderId?: string; + deliveryEndHour?: string; } interface StepReviewProps { @@ -272,6 +273,9 @@ export async function StepReview({ params }: StepReviewProps) { rrule={rrule} editReminderId={editReminderId} timezone={timezone} + deliveryEndHour={ + params.deliveryEndHour ? Number(params.deliveryEndHour) : undefined + } /> ); diff --git a/apps/web/src/components/reminder-wizard/step-when.tsx b/apps/web/src/components/reminder-wizard/step-when.tsx index 19d41ec..142784d 100644 --- a/apps/web/src/components/reminder-wizard/step-when.tsx +++ b/apps/web/src/components/reminder-wizard/step-when.tsx @@ -23,6 +23,7 @@ interface StepWhenParams { scheduledAt?: string; rrule?: string; editReminderId?: string; + deliveryEndHour?: string; } interface StepWhenProps { @@ -87,6 +88,9 @@ export async function StepWhen({ params }: StepWhenProps) { timezone={timezone} initialDefaultIso={scheduledAt ?? defaultFirstFireIso(timezone)} initialSpec={specFromRrule(rrule)} + initialDeliveryEndHour={ + params.deliveryEndHour ? Number(params.deliveryEndHour) : undefined + } passThroughParams={{ name: params.name, messages: messagesParam, editReminderId }} /> 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 4e13a46..8860688 100644 --- a/apps/web/src/components/reminder-wizard/when-form-client.tsx +++ b/apps/web/src/components/reminder-wizard/when-form-client.tsx @@ -10,6 +10,7 @@ import { Label } from "@/components/ui/label"; import { buildRrule, DEFAULT_RECURRENCE, type RecurrenceSpec } from "@/lib/recurrence"; import { splitDateTime, validateScheduledAt } from "@/lib/date-picker"; import { RecurrencePicker } from "@/components/recurrence-picker"; +import { HourSelect } from "@/components/hour-select"; interface PassThroughParams { /** User-supplied reminder name (passes through unchanged). */ @@ -25,6 +26,7 @@ interface WhenFormClientProps { timezone: string; initialDefaultIso: string; initialSpec?: RecurrenceSpec; + initialDeliveryEndHour?: number; passThroughParams: PassThroughParams; } @@ -34,6 +36,7 @@ export function WhenFormClient({ timezone, initialDefaultIso, initialSpec, + initialDeliveryEndHour, passThroughParams, }: WhenFormClientProps) { const router = useRouter(); @@ -42,6 +45,9 @@ export function WhenFormClient({ const [date, setDate] = useState(initial.date); const [time, setTime] = useState(initial.time); const [spec, setSpec] = useState(initialSpec ?? DEFAULT_RECURRENCE); + const [deliveryEndHour, setDeliveryEndHour] = useState( + initialDeliveryEndHour ?? 18, + ); const [error, setError] = useState(null); // The first-fire DateTime drives preset labels in the picker. Fall @@ -71,6 +77,7 @@ 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)); // eslint-disable-next-line @typescript-eslint/no-explicit-any router.push(`/reminders/new?${sp.toString()}` as any); return; @@ -114,6 +121,7 @@ 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)); // eslint-disable-next-line @typescript-eslint/no-explicit-any router.push(`/reminders/new?${sp.toString()}` as any); } @@ -158,6 +166,28 @@ 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. */} +
+ +
+ { + setDeliveryEndHour(h); + setError(null); + }} + /> + ({timezone}) +
+
+ {error && (
diff --git a/apps/web/src/lib/queries.ts b/apps/web/src/lib/queries.ts index 6e80e6f..74a5262 100644 --- a/apps/web/src/lib/queries.ts +++ b/apps/web/src/lib/queries.ts @@ -30,8 +30,11 @@ export async function getDashboardStats(operatorId: string) { `); return { connectedAccounts: accounts.filter((a) => a.status === "connected").length, + unpairedAccounts: accounts.filter((a) => a.status === "unpaired").length, totalAccounts: accounts.length, activeReminders: allReminders.filter((r) => r.status === "active").length, + pausedReminders: allReminders.filter((r) => r.status === "paused").length, + endedReminders: allReminders.filter((r) => r.status === "ended").length, totalReminders: allReminders.length, recentRuns: recentRuns.rows as Array<{ id: string;