diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index b6cc2fd..044d595 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -40,7 +40,7 @@ import { getSeededOperator } from "@/lib/operator"; import { getDashboardStats } from "@/lib/queries"; // --------------------------------------------------------------------------- -// Relative time helper (no external dep, server-safe) +// Time helpers (no external dep, server-safe) // --------------------------------------------------------------------------- function relativeTime(date: Date | string): string { const d = typeof date === "string" ? new Date(date) : date; @@ -54,6 +54,20 @@ function relativeTime(date: Date | string): string { return rtf.format(-Math.floor(diffSec / 86400), "day"); } +/** Absolute-time fallback used as a tooltip on relative-time displays. + * 12-hour format with AM/PM so the user can read it at a glance. */ +function absoluteTime(date: Date | string): string { + const d = typeof date === "string" ? new Date(date) : date; + return new Intl.DateTimeFormat("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + hour: "numeric", + minute: "2-digit", + hour12: true, + }).format(d); +} + // --------------------------------------------------------------------------- // Run-status pill // --------------------------------------------------------------------------- @@ -159,28 +173,21 @@ export default async function DashboardPage() {

Dashboard

{/* Stat cards — click to drill into the corresponding tab */} -
+
-
{/* Recent activity */} @@ -242,9 +249,13 @@ export default async function DashboardPage() { )}

-

- {relativeTime(run.fired_at)} -

+
@@ -304,7 +315,15 @@ export default async function DashboardPage() { - {relativeTime(run.fired_at)} + + + {relativeTime(run.fired_at)} + ); diff --git a/apps/web/src/app/reminders/[id]/edit/when/page.tsx b/apps/web/src/app/reminders/[id]/edit/when/page.tsx index 76031f7..47a18c6 100644 --- a/apps/web/src/app/reminders/[id]/edit/when/page.tsx +++ b/apps/web/src/app/reminders/[id]/edit/when/page.tsx @@ -44,6 +44,7 @@ export default async function EditWhenPage({ params }: Props) { name={reminder.name} initialIso={(reminder.scheduledAt ?? new Date()).toISOString()} initialSpec={specFromRrule(reminder.rrule)} + initialDeliveryEndHour={reminder.deliveryWindowEndHour} timezone={reminder.timezone} /> diff --git a/apps/web/src/components/hour-select.test.tsx b/apps/web/src/components/hour-select.test.tsx new file mode 100644 index 0000000..9142cdf --- /dev/null +++ b/apps/web/src/components/hour-select.test.tsx @@ -0,0 +1,83 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { to12Hour, from12Hour, HourSelect } from "./hour-select"; + +describe("to12Hour", () => { + it("maps 0 → 12 AM (midnight)", () => { + expect(to12Hour(0)).toEqual({ hour12: 12, period: "AM" }); + }); + + it("maps 12 → 12 PM (noon)", () => { + expect(to12Hour(12)).toEqual({ hour12: 12, period: "PM" }); + }); + + it("maps morning hours (1..11) to AM, same digit", () => { + expect(to12Hour(1)).toEqual({ hour12: 1, period: "AM" }); + expect(to12Hour(6)).toEqual({ hour12: 6, period: "AM" }); + expect(to12Hour(11)).toEqual({ hour12: 11, period: "AM" }); + }); + + it("maps afternoon/evening hours (13..23) to PM, digit minus 12", () => { + expect(to12Hour(13)).toEqual({ hour12: 1, period: "PM" }); + expect(to12Hour(18)).toEqual({ hour12: 6, period: "PM" }); + expect(to12Hour(23)).toEqual({ hour12: 11, period: "PM" }); + }); +}); + +describe("from12Hour", () => { + it("maps 12 AM → 0", () => { + expect(from12Hour(12, "AM")).toBe(0); + }); + + it("maps 12 PM → 12", () => { + expect(from12Hour(12, "PM")).toBe(12); + }); + + it("maps 1..11 AM identity", () => { + expect(from12Hour(1, "AM")).toBe(1); + expect(from12Hour(11, "AM")).toBe(11); + }); + + it("maps 1..11 PM as digit + 12", () => { + expect(from12Hour(1, "PM")).toBe(13); + expect(from12Hour(6, "PM")).toBe(18); + expect(from12Hour(11, "PM")).toBe(23); + }); + + it("round-trips with to12Hour for every 0..23 value", () => { + for (let h = 0; h <= 23; h++) { + const { hour12, period } = to12Hour(h); + expect(from12Hour(hour12, period)).toBe(h); + } + }); +}); + +describe("HourSelect", () => { + it("renders both selects with twelve hour options and AM/PM", () => { + const html = renderToStaticMarkup( + {}} ariaPrefix="Delivery start" />, + ); + // 12 hour options total + expect((html.match(/"); + expect(html).toContain(">PM"); + }); + + it("pre-selects the right hour and period from a 24-hour value", () => { + // 6 → 6 AM + const morning = renderToStaticMarkup( + {}} ariaPrefix="x" />, + ); + expect(morning).toMatch(/value="6"\s+selected/); + expect(morning).toMatch(/value="AM"\s+selected/); + + // 18 → 6 PM + const evening = renderToStaticMarkup( + {}} ariaPrefix="y" />, + ); + expect(evening).toMatch(/value="6"\s+selected/); + expect(evening).toMatch(/value="PM"\s+selected/); + }); +}); diff --git a/apps/web/src/components/hour-select.tsx b/apps/web/src/components/hour-select.tsx new file mode 100644 index 0000000..f882d19 --- /dev/null +++ b/apps/web/src/components/hour-select.tsx @@ -0,0 +1,62 @@ +"use client"; + +interface HourSelectProps { + value: number; // 0..23 + onChange: (hour: number) => void; + ariaPrefix: string; +} + +const HOURS_12H = [12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; +type Period = "AM" | "PM"; + +/** Convert a 0..23 24-hour value to its 12-hour + period form. */ +export function to12Hour(h: number): { hour12: number; period: Period } { + if (h === 0) return { hour12: 12, period: "AM" }; + if (h === 12) return { hour12: 12, period: "PM" }; + if (h < 12) return { hour12: h, period: "AM" }; + return { hour12: h - 12, period: "PM" }; +} + +/** Convert a 12-hour + period pair back to a 0..23 24-hour value. */ +export function from12Hour(hour12: number, period: Period): number { + if (period === "AM") return hour12 === 12 ? 0 : hour12; + return hour12 === 12 ? 12 : hour12 + 12; +} + +const SELECT_CLASS = + "h-9 min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-sm transition-colors outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 dark:bg-input/30"; + +/** + * Two side-by-side onChange(from12Hour(Number(e.target.value), period))} + className={SELECT_CLASS} + > + {HOURS_12H.map((h) => ( + + ))} + + + + ); +} 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;