feat(web): pause-by-hour deadline + AM/PM dropdowns + dashboard tweaks

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 <select>s and
emits a single 0..23 value. to12Hour / from12Hour are pure helpers
covered by 11 round-trip tests.

Dashboard adjustments:
* "WhatsApp accounts" card now reads Connected / Unpaired / Total.
* "Reminders" card reads Active / Paused / Ended / Total.
* "Recent runs" stat card removed (the Recent activity section below
  shows the same info).
* Activity rows show absolute timestamp with AM/PM and relative time
  in tandem.

Accounts list:
* The page-level <h1>Accounts</h1> is hidden on mobile (the top bar
  already shows it), matching the Dashboard pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-05-10 15:07:25 +08:00
parent f96eea8e93
commit bf49b80431
12 changed files with 263 additions and 19 deletions

View File

@ -40,7 +40,7 @@ import { getSeededOperator } from "@/lib/operator";
import { getDashboardStats } from "@/lib/queries"; 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 { function relativeTime(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date; 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"); 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 // Run-status pill
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -159,28 +173,21 @@ export default async function DashboardPage() {
<h1 className="hidden sm:block text-2xl font-semibold tracking-tight">Dashboard</h1> <h1 className="hidden sm:block text-2xl font-semibold tracking-tight">Dashboard</h1>
{/* Stat cards — click to drill into the corresponding tab */} {/* Stat cards — click to drill into the corresponding tab */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<StatCard <StatCard
title="Accounts connected" title="WhatsApp accounts"
value={`${stats.connectedAccounts} / ${stats.totalAccounts}`} value={`${stats.connectedAccounts} / ${stats.unpairedAccounts} / ${stats.totalAccounts}`}
icon={WifiIcon} icon={WifiIcon}
description="WhatsApp accounts" description="Connected / Unpaired / Total"
href="/accounts" href="/accounts"
/> />
<StatCard <StatCard
title="Reminders" title="Reminders"
value={`${stats.activeReminders} / ${stats.totalReminders}`} value={`${stats.activeReminders} / ${stats.pausedReminders} / ${stats.endedReminders} / ${stats.totalReminders}`}
icon={BellIcon} icon={BellIcon}
description="Active / total" description="Active / Paused / Ended / Total"
href="/reminders" href="/reminders"
/> />
<StatCard
title="Recent runs"
value={stats.recentRuns.length}
icon={ActivityIcon}
description="3 most recent runs"
href="/activity"
/>
</div> </div>
{/* Recent activity */} {/* Recent activity */}
@ -242,9 +249,13 @@ export default async function DashboardPage() {
</span> </span>
)} )}
</p> </p>
<p className="text-xs text-muted-foreground mt-0.5"> <time
{relativeTime(run.fired_at)} dateTime={new Date(run.fired_at).toISOString()}
</p> title={absoluteTime(run.fired_at)}
className="text-xs text-muted-foreground mt-0.5 block"
>
{absoluteTime(run.fired_at)} · {relativeTime(run.fired_at)}
</time>
</div> </div>
<RunStatusBadge status={run.status} /> <RunStatusBadge status={run.status} />
</CardContent> </CardContent>
@ -304,7 +315,15 @@ export default async function DashboardPage() {
<RunStatusBadge status={run.status} /> <RunStatusBadge status={run.status} />
</TableCell> </TableCell>
<TableCell className="text-right text-muted-foreground text-xs"> <TableCell className="text-right text-muted-foreground text-xs">
{relativeTime(run.fired_at)} <time
dateTime={new Date(run.fired_at).toISOString()}
title={absoluteTime(run.fired_at)}
>
{absoluteTime(run.fired_at)}
</time>
<span className="block text-[10px] opacity-75">
{relativeTime(run.fired_at)}
</span>
</TableCell> </TableCell>
</TableRow> </TableRow>
); );

View File

@ -44,6 +44,7 @@ export default async function EditWhenPage({ params }: Props) {
name={reminder.name} name={reminder.name}
initialIso={(reminder.scheduledAt ?? new Date()).toISOString()} initialIso={(reminder.scheduledAt ?? new Date()).toISOString()}
initialSpec={specFromRrule(reminder.rrule)} initialSpec={specFromRrule(reminder.rrule)}
initialDeliveryEndHour={reminder.deliveryWindowEndHour}
timezone={reminder.timezone} timezone={reminder.timezone}
/> />
</EditShell> </EditShell>

View File

@ -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(
<HourSelect value={6} onChange={() => {}} ariaPrefix="Delivery start" />,
);
// 12 hour options total
expect((html.match(/<option /g) ?? []).length).toBe(14); // 12 hours + AM + PM
expect(html).toContain('aria-label="Delivery start hour"');
expect(html).toContain('aria-label="Delivery start period"');
expect(html).toContain(">AM</option>");
expect(html).toContain(">PM</option>");
});
it("pre-selects the right hour and period from a 24-hour value", () => {
// 6 → 6 AM
const morning = renderToStaticMarkup(
<HourSelect value={6} onChange={() => {}} ariaPrefix="x" />,
);
expect(morning).toMatch(/value="6"\s+selected/);
expect(morning).toMatch(/value="AM"\s+selected/);
// 18 → 6 PM
const evening = renderToStaticMarkup(
<HourSelect value={18} onChange={() => {}} ariaPrefix="y" />,
);
expect(evening).toMatch(/value="6"\s+selected/);
expect(evening).toMatch(/value="PM"\s+selected/);
});
});

View File

@ -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 <select> 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 (
<div className="flex items-center gap-1">
<select
aria-label={`${ariaPrefix} hour`}
value={hour12}
onChange={(e) => onChange(from12Hour(Number(e.target.value), period))}
className={SELECT_CLASS}
>
{HOURS_12H.map((h) => (
<option key={h} value={h}>
{h}
</option>
))}
</select>
<select
aria-label={`${ariaPrefix} period`}
value={period}
onChange={(e) => onChange(from12Hour(hour12, e.target.value as Period))}
className={SELECT_CLASS}
>
<option value="AM">AM</option>
<option value="PM">PM</option>
</select>
</div>
);
}

View File

@ -16,6 +16,7 @@ import { Label } from "@/components/ui/label";
import { splitDateTime, validateScheduledAt } from "@/lib/date-picker"; import { splitDateTime, validateScheduledAt } from "@/lib/date-picker";
import { buildRrule, type RecurrenceSpec } from "@/lib/recurrence"; import { buildRrule, type RecurrenceSpec } from "@/lib/recurrence";
import { RecurrencePicker } from "@/components/recurrence-picker"; import { RecurrencePicker } from "@/components/recurrence-picker";
import { HourSelect } from "@/components/hour-select";
import { updateReminderAction } from "@/actions/reminders"; import { updateReminderAction } from "@/actions/reminders";
import type { MessagePart } from "@/lib/reminder-messages"; import type { MessagePart } from "@/lib/reminder-messages";
@ -30,6 +31,7 @@ interface EditWhenFormProps {
name: string; name: string;
initialIso: string; initialIso: string;
initialSpec: RecurrenceSpec; initialSpec: RecurrenceSpec;
initialDeliveryEndHour: number;
timezone: string; timezone: string;
} }
@ -41,6 +43,7 @@ export function EditWhenForm({
name, name,
initialIso, initialIso,
initialSpec, initialSpec,
initialDeliveryEndHour,
timezone, timezone,
}: EditWhenFormProps) { }: EditWhenFormProps) {
const router = useRouter(); const router = useRouter();
@ -49,6 +52,7 @@ export function EditWhenForm({
const [date, setDate] = useState(initial.date); const [date, setDate] = useState(initial.date);
const [time, setTime] = useState(initial.time); const [time, setTime] = useState(initial.time);
const [spec, setSpec] = useState<RecurrenceSpec>(initialSpec); const [spec, setSpec] = useState<RecurrenceSpec>(initialSpec);
const [deliveryEndHour, setDeliveryEndHour] = useState<number>(initialDeliveryEndHour);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -104,6 +108,7 @@ export function EditWhenForm({
scheduledAtIso, scheduledAtIso,
rrule, rrule,
timezone, timezone,
deliveryWindowEndHour: deliveryEndHour,
}); });
if (r.ok) { if (r.ok) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -157,6 +162,25 @@ export function EditWhenForm({
<RecurrencePicker firstFire={previewDt} value={spec} onChange={setSpec} /> <RecurrencePicker firstFire={previewDt} value={spec} onChange={setSpec} />
<div className="space-y-1.5">
<Label className="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">
<HourSelect
ariaPrefix="Delivery deadline"
value={deliveryEndHour}
onChange={(h) => {
setDeliveryEndHour(h);
setError(null);
}}
/>
<span className="text-xs text-muted-foreground">({timezone})</span>
</div>
</div>
{error && ( {error && (
<div className="flex items-center gap-1.5 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive"> <div className="flex items-center gap-1.5 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertCircleIcon className="size-3.5 shrink-0" /> <AlertCircleIcon className="size-3.5 shrink-0" />

View File

@ -22,6 +22,7 @@ interface PassThroughParams {
scheduledAt?: string; scheduledAt?: string;
rrule?: string; rrule?: string;
editReminderId?: string; editReminderId?: string;
deliveryEndHour?: string;
} }
interface GroupsFormClientProps { interface GroupsFormClientProps {
@ -74,6 +75,7 @@ export function GroupsFormClient({
if (passThroughParams.scheduledAt) sp.set("scheduledAt", passThroughParams.scheduledAt); if (passThroughParams.scheduledAt) sp.set("scheduledAt", passThroughParams.scheduledAt);
if (passThroughParams.rrule) sp.set("rrule", passThroughParams.rrule); if (passThroughParams.rrule) sp.set("rrule", passThroughParams.rrule);
if (passThroughParams.editReminderId) sp.set("editReminderId", passThroughParams.editReminderId); 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any
router.push(`/reminders/new?${sp.toString()}` as any); router.push(`/reminders/new?${sp.toString()}` as any);
} }

View File

@ -17,6 +17,7 @@ interface ReviewSubmitClientProps {
rrule?: string; rrule?: string;
editReminderId?: string; editReminderId?: string;
timezone: string; timezone: string;
deliveryEndHour?: number;
} }
export function ReviewSubmitClient({ export function ReviewSubmitClient({
@ -28,6 +29,7 @@ export function ReviewSubmitClient({
rrule, rrule,
editReminderId, editReminderId,
timezone, timezone,
deliveryEndHour,
}: ReviewSubmitClientProps) { }: ReviewSubmitClientProps) {
const router = useRouter(); const router = useRouter();
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
@ -55,6 +57,7 @@ export function ReviewSubmitClient({
scheduledAtIso: scheduledAt, scheduledAtIso: scheduledAt,
rrule: rrule ?? null, rrule: rrule ?? null,
timezone, timezone,
deliveryWindowEndHour: deliveryEndHour,
}; };
const result = editReminderId const result = editReminderId
? await updateReminderAction({ ...payload, reminderId: editReminderId }) ? await updateReminderAction({ ...payload, reminderId: editReminderId })

View File

@ -20,6 +20,7 @@ interface StepGroupsParams {
rrule?: string; rrule?: string;
groupId?: string; groupId?: string;
editReminderId?: string; editReminderId?: string;
deliveryEndHour?: string;
} }
interface StepGroupsProps { interface StepGroupsProps {
@ -76,6 +77,7 @@ export async function StepGroups({ params }: StepGroupsProps) {
if (scheduledAt) backParams.set("scheduledAt", scheduledAt); if (scheduledAt) backParams.set("scheduledAt", scheduledAt);
if (rrule) backParams.set("rrule", rrule); if (rrule) backParams.set("rrule", rrule);
if (editReminderId) backParams.set("editReminderId", editReminderId); if (editReminderId) backParams.set("editReminderId", editReminderId);
if (params.deliveryEndHour) backParams.set("deliveryEndHour", params.deliveryEndHour);
const backHref = `/reminders/new?${backParams.toString()}`; const backHref = `/reminders/new?${backParams.toString()}`;
return ( return (
@ -99,7 +101,14 @@ export async function StepGroups({ params }: StepGroupsProps) {
groups={groups} groups={groups}
preSelected={preSelected} preSelected={preSelected}
accountId={accountId} accountId={accountId}
passThroughParams={{ name: params.name, messages, scheduledAt, rrule, editReminderId }} passThroughParams={{
name: params.name,
messages,
scheduledAt,
rrule,
editReminderId,
deliveryEndHour: params.deliveryEndHour,
}}
/> />
</div> </div>
); );

View File

@ -38,6 +38,7 @@ interface StepReviewParams {
scheduledAt?: string; scheduledAt?: string;
rrule?: string; rrule?: string;
editReminderId?: string; editReminderId?: string;
deliveryEndHour?: string;
} }
interface StepReviewProps { interface StepReviewProps {
@ -272,6 +273,9 @@ export async function StepReview({ params }: StepReviewProps) {
rrule={rrule} rrule={rrule}
editReminderId={editReminderId} editReminderId={editReminderId}
timezone={timezone} timezone={timezone}
deliveryEndHour={
params.deliveryEndHour ? Number(params.deliveryEndHour) : undefined
}
/> />
</div> </div>
); );

View File

@ -23,6 +23,7 @@ interface StepWhenParams {
scheduledAt?: string; scheduledAt?: string;
rrule?: string; rrule?: string;
editReminderId?: string; editReminderId?: string;
deliveryEndHour?: string;
} }
interface StepWhenProps { interface StepWhenProps {
@ -87,6 +88,9 @@ export async function StepWhen({ params }: StepWhenProps) {
timezone={timezone} timezone={timezone}
initialDefaultIso={scheduledAt ?? defaultFirstFireIso(timezone)} initialDefaultIso={scheduledAt ?? defaultFirstFireIso(timezone)}
initialSpec={specFromRrule(rrule)} initialSpec={specFromRrule(rrule)}
initialDeliveryEndHour={
params.deliveryEndHour ? Number(params.deliveryEndHour) : undefined
}
passThroughParams={{ name: params.name, messages: messagesParam, editReminderId }} passThroughParams={{ name: params.name, messages: messagesParam, editReminderId }}
/> />
</div> </div>

View File

@ -10,6 +10,7 @@ import { Label } from "@/components/ui/label";
import { buildRrule, DEFAULT_RECURRENCE, type RecurrenceSpec } from "@/lib/recurrence"; import { buildRrule, DEFAULT_RECURRENCE, type RecurrenceSpec } from "@/lib/recurrence";
import { splitDateTime, validateScheduledAt } from "@/lib/date-picker"; import { splitDateTime, validateScheduledAt } from "@/lib/date-picker";
import { RecurrencePicker } from "@/components/recurrence-picker"; import { RecurrencePicker } from "@/components/recurrence-picker";
import { HourSelect } from "@/components/hour-select";
interface PassThroughParams { interface PassThroughParams {
/** User-supplied reminder name (passes through unchanged). */ /** User-supplied reminder name (passes through unchanged). */
@ -25,6 +26,7 @@ interface WhenFormClientProps {
timezone: string; timezone: string;
initialDefaultIso: string; initialDefaultIso: string;
initialSpec?: RecurrenceSpec; initialSpec?: RecurrenceSpec;
initialDeliveryEndHour?: number;
passThroughParams: PassThroughParams; passThroughParams: PassThroughParams;
} }
@ -34,6 +36,7 @@ export function WhenFormClient({
timezone, timezone,
initialDefaultIso, initialDefaultIso,
initialSpec, initialSpec,
initialDeliveryEndHour,
passThroughParams, passThroughParams,
}: WhenFormClientProps) { }: WhenFormClientProps) {
const router = useRouter(); const router = useRouter();
@ -42,6 +45,9 @@ export function WhenFormClient({
const [date, setDate] = useState(initial.date); const [date, setDate] = useState(initial.date);
const [time, setTime] = useState(initial.time); const [time, setTime] = useState(initial.time);
const [spec, setSpec] = useState<RecurrenceSpec>(initialSpec ?? DEFAULT_RECURRENCE); const [spec, setSpec] = useState<RecurrenceSpec>(initialSpec ?? DEFAULT_RECURRENCE);
const [deliveryEndHour, setDeliveryEndHour] = useState<number>(
initialDeliveryEndHour ?? 18,
);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// The first-fire DateTime drives preset labels in the picker. Fall // 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.name) sp.set("name", passThroughParams.name);
if (passThroughParams.messages) sp.set("messages", passThroughParams.messages); if (passThroughParams.messages) sp.set("messages", passThroughParams.messages);
if (passThroughParams.editReminderId) sp.set("editReminderId", passThroughParams.editReminderId); if (passThroughParams.editReminderId) sp.set("editReminderId", passThroughParams.editReminderId);
sp.set("deliveryEndHour", String(deliveryEndHour));
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
router.push(`/reminders/new?${sp.toString()}` as any); router.push(`/reminders/new?${sp.toString()}` as any);
return; return;
@ -114,6 +121,7 @@ export function WhenFormClient({
if (passThroughParams.name) sp.set("name", passThroughParams.name); if (passThroughParams.name) sp.set("name", passThroughParams.name);
if (passThroughParams.messages) sp.set("messages", passThroughParams.messages); if (passThroughParams.messages) sp.set("messages", passThroughParams.messages);
if (passThroughParams.editReminderId) sp.set("editReminderId", passThroughParams.editReminderId); if (passThroughParams.editReminderId) sp.set("editReminderId", passThroughParams.editReminderId);
sp.set("deliveryEndHour", String(deliveryEndHour));
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
router.push(`/reminders/new?${sp.toString()}` as any); router.push(`/reminders/new?${sp.toString()}` as any);
} }
@ -158,6 +166,28 @@ export function WhenFormClient({
<RecurrencePicker firstFire={previewDt} value={spec} onChange={setSpec} /> <RecurrencePicker firstFire={previewDt} value={spec} onChange={setSpec} />
{/* 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. */}
<div className="space-y-1.5">
<Label className="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">
<HourSelect
ariaPrefix="Delivery deadline"
value={deliveryEndHour}
onChange={(h) => {
setDeliveryEndHour(h);
setError(null);
}}
/>
<span className="text-xs text-muted-foreground">({timezone})</span>
</div>
</div>
{error && ( {error && (
<div className="flex items-center gap-1.5 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive"> <div className="flex items-center gap-1.5 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertCircleIcon className="size-3.5 shrink-0" /> <AlertCircleIcon className="size-3.5 shrink-0" />

View File

@ -30,8 +30,11 @@ export async function getDashboardStats(operatorId: string) {
`); `);
return { return {
connectedAccounts: accounts.filter((a) => a.status === "connected").length, connectedAccounts: accounts.filter((a) => a.status === "connected").length,
unpairedAccounts: accounts.filter((a) => a.status === "unpaired").length,
totalAccounts: accounts.length, totalAccounts: accounts.length,
activeReminders: allReminders.filter((r) => r.status === "active").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, totalReminders: allReminders.length,
recentRuns: recentRuns.rows as Array<{ recentRuns: recentRuns.rows as Array<{
id: string; id: string;