feat(reminders): search + filter + sort on the list, Pause/Restart/Delete on detail

/reminders list
- New ReminderFilterBar (client component, URL-driven):
  * Free-text search across reminder name, first message text,
    account label, and target group names. Debounced 250 ms.
  * Account dropdown — filters to one paired account.
  * Group dropdown — narrows to a single group; auto-scoped to the
    chosen account.
  * Sort dropdown — Newest first / Oldest first / Recently created /
    Name A→Z. Default is `scheduled_desc`.
- Status tabs (All / Active / Ended / Paused) preserve all other
  filter params when flipping, so changing tab doesn't lose context.
- Empty-state copy is filter-aware ("No reminders match your filters."
  vs "No <status> reminders yet.").
- Pure helpers in `lib/reminder-filter.ts` so the same q+account+
  group+status+sort logic can be unit-tested without a DB.

/reminders/[id] detail
- New ActionsBar (Pause / Restart / Delete) replaces the bare delete
  button. Each card is a transparent <button> overlay over a Card
  (no <button>-wrapping-Card — the static guard keeps it that way).
  Confirm dialogs gate every destructive action.
  - Pause: visible only when status === "active"; flips to "paused".
  - Restart: visible when status is "paused" or "ended". For a
    recurring reminder, computes the next occurrence from the RRULE
    and re-arms pg-boss; for a one-off reminder it sets the next
    fire to "now + 1 minute".
  - Delete: always available (run history is preserved on /activity).

Server actions
- `pauseReminderAction(formData)` — sets status="paused" if active.
- `restartReminderAction(formData)` — recomputes next fire and
  re-arms via pg_notify(`reminder.schedule`).
- The existing deleteReminderAction is reused.

`lib/queries.ts#listReminders`
- Now also returns accountId, group ids, joined group names, and the
  first message text — fields the search/filter logic needs.
- Coerces SQL timestamp strings to Date objects (raw `db.execute(sql)`
  hands them back as strings, which broke .getTime() in the sorter).

Tests (+22 new, 130 web tests + 26 bot tests = 156 across the repo)
- lib/reminder-filter.test.ts (16 tests):
  * search hits across all four indexed fields, case-insensitive
  * account / group / status filters
  * every sort key, including handling of null scheduledAt
  * combined AND-of-all-filters check
- app/reminders/[id]/actions-bar.test.tsx (6 tests):
  * Pause card only shown for `active`
  * Restart card only shown for `paused` / `ended`
  * Delete card always rendered
  * Restart description differs for recurring vs one-off
  * every confirm dialog carries the matching `reminderId` hidden input

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-05-10 10:11:46 +08:00
parent ec57a78853
commit 50df7fcb11
10 changed files with 964 additions and 104 deletions

View File

@ -45,6 +45,89 @@ export async function deleteReminderAction(formData: FormData): Promise<void> {
redirect("/reminders" as any);
}
/**
* Resolve and verify the reminder owned by the seeded operator. Returns
* null if the reminder doesn't exist or belongs to a different account.
*/
async function loadOwnedReminder(reminderId: string) {
const op = await getSeededOperator();
const reminder = await db.query.reminders.findFirst({
where: (r, { eq }) => eq(r.id, reminderId),
});
if (!reminder) return null;
const account = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) => and(eq(a.id, reminder.accountId), eq(a.operatorId, op.id)),
});
if (!account) return null;
return reminder;
}
/**
* Pause an active reminder. The pg-boss job stays armed (we don't have
* a hard cancel) but `fireReminder` exits early when status !== "active".
*/
export async function pauseReminderAction(formData: FormData): Promise<void> {
await rateLimit("pause-reminder");
const reminderId = formData.get("reminderId");
if (typeof reminderId !== "string") return;
const reminder = await loadOwnedReminder(reminderId);
if (!reminder) return;
if (reminder.status !== "active") return; // already not running
await db
.update(reminders)
.set({ status: "paused", updatedAt: new Date() })
.where(eq(reminders.id, reminderId));
revalidatePath("/reminders" as any);
revalidatePath(`/reminders/${reminderId}` as any);
}
/**
* Restart a paused or ended reminder. For a one-off whose scheduledAt is
* in the past, push it to "now + 1 minute" so it fires soon. For a
* recurring reminder, compute the next occurrence from the RRULE.
* Either way the row flips back to `active` and the pg-boss job is
* re-armed.
*/
export async function restartReminderAction(formData: FormData): Promise<void> {
await rateLimit("restart-reminder");
const reminderId = formData.get("reminderId");
if (typeof reminderId !== "string") return;
const reminder = await loadOwnedReminder(reminderId);
if (!reminder) return;
let nextFire: Date | null = null;
const now = new Date();
if (reminder.scheduleKind === "recurring" && reminder.rrule) {
const { nextOccurrence } = await import("@cmbot/shared");
nextFire = nextOccurrence(reminder.rrule, reminder.timezone, now);
} else if (reminder.scheduledAt && reminder.scheduledAt.getTime() > Date.now() + 30_000) {
// The original time is still in the future and far enough away to
// be useful — keep it.
nextFire = reminder.scheduledAt;
} else {
nextFire = new Date(Date.now() + 60_000);
}
if (!nextFire) return;
await db
.update(reminders)
.set({
status: "active",
scheduledAt: nextFire,
updatedAt: now,
})
.where(eq(reminders.id, reminderId));
await pgNotifyBot({
type: "reminder.schedule",
reminderId,
scheduledAtIso: nextFire.toISOString(),
});
revalidatePath("/reminders" as any);
revalidatePath(`/reminders/${reminderId}` as any);
}
const createReminderSchema = z
.object({
accountId: z.string().uuid(),

View File

@ -0,0 +1,89 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import type { ReactNode } from "react";
vi.mock("@/actions/reminders", () => ({
pauseReminderAction: vi.fn(),
restartReminderAction: vi.fn(),
deleteReminderAction: vi.fn(),
}));
// Make Dialog primitives transparent so we can grep the underlying tree.
vi.mock("@/components/ui/dialog", () => ({
Dialog: ({ children }: { children: ReactNode }) => <>{children}</>,
DialogTrigger: ({ children }: { children: ReactNode; asChild?: boolean }) => <>{children}</>,
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children: ReactNode }) => <h2>{children}</h2>,
DialogDescription: ({ children }: { children: ReactNode }) => <p>{children}</p>,
DialogFooter: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
import { ActionsBar } from "./actions-bar";
describe("ActionsBar — card visibility by status", () => {
it("active: shows Pause and Delete (no Restart)", () => {
const html = renderToStaticMarkup(
<ActionsBar reminderId="r-1" status="active" isRecurring={false} />,
);
expect(html).toMatch(/aria-label="Pause"/);
expect(html).toMatch(/aria-label="Delete"/);
expect(html).not.toMatch(/aria-label="Restart"/);
});
it("paused: shows Restart and Delete (no Pause)", () => {
const html = renderToStaticMarkup(
<ActionsBar reminderId="r-1" status="paused" isRecurring={false} />,
);
expect(html).toMatch(/aria-label="Restart"/);
expect(html).toMatch(/aria-label="Delete"/);
expect(html).not.toMatch(/aria-label="Pause"/);
});
it("ended: shows Restart and Delete (no Pause)", () => {
const html = renderToStaticMarkup(
<ActionsBar reminderId="r-1" status="ended" isRecurring={false} />,
);
expect(html).toMatch(/aria-label="Restart"/);
expect(html).toMatch(/aria-label="Delete"/);
expect(html).not.toMatch(/aria-label="Pause"/);
});
it("any other terminal status (banned, etc.): only Delete is offered", () => {
const html = renderToStaticMarkup(
<ActionsBar reminderId="r-1" status="failed" isRecurring={false} />,
);
expect(html).toMatch(/aria-label="Delete"/);
expect(html).not.toMatch(/aria-label="Pause"/);
expect(html).not.toMatch(/aria-label="Restart"/);
});
});
describe("ActionsBar — copy varies for recurring vs one-off restart", () => {
it("recurring: Restart description mentions next occurrence", () => {
const html = renderToStaticMarkup(
<ActionsBar reminderId="r-1" status="paused" isRecurring={true} />,
);
expect(html).toContain("Activate and re-arm at next occurrence");
});
it("one-off: Restart description says it'll fire in ~1 minute", () => {
const html = renderToStaticMarkup(
<ActionsBar reminderId="r-1" status="paused" isRecurring={false} />,
);
expect(html).toContain("Activate and fire in ~1 minute");
});
});
describe("ActionsBar — every confirm dialog carries the reminderId", () => {
it("hidden inputs match the supplied id, in every visible card", () => {
const html = renderToStaticMarkup(
<ActionsBar reminderId="abc-uuid" status="active" isRecurring={false} />,
);
// Pause and Delete should each have a hidden input with the id.
const matches = html.match(
/<input[^>]+type="hidden"[^>]+name="reminderId"[^>]+value="abc-uuid"/g,
);
expect(matches?.length ?? 0).toBeGreaterThanOrEqual(2);
});
});

View File

@ -0,0 +1,191 @@
"use client";
import { useState } from "react";
import {
AlertCircleIcon,
Loader2Icon,
PauseIcon,
PlayIcon,
Trash2Icon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
deleteReminderAction,
pauseReminderAction,
restartReminderAction,
} from "@/actions/reminders";
interface ActionsBarProps {
reminderId: string;
status: string;
isRecurring: boolean;
}
/**
* Lifecycle controls for a reminder. Three cards rendered side-by-side
* on desktop, stacked on mobile:
*
* - Pause only when status === "active"
* - Restart when status is "paused" or "ended"
* - Delete always available (terminal)
*
* Each Dialog confirms before firing the corresponding server action.
* No <button>-wrapping-Card nesting (caught by the static guard test).
*/
export function ActionsBar({ reminderId, status, isRecurring }: ActionsBarProps) {
const canPause = status === "active";
const canRestart = status === "paused" || status === "ended";
return (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
{canPause && (
<ConfirmCard
title="Pause"
description="Stop firing until you restart"
icon={<PauseIcon className="size-4 text-amber-600 dark:text-amber-400" />}
accentBg="bg-amber-500/10"
accentRing="hover:ring-amber-500/30"
dialogTitle="Pause this reminder?"
dialogBody="It won't fire while paused. You can restart it later from this page."
confirmLabel="Yes, pause"
confirmVariant="default"
confirmIcon={<PauseIcon />}
action={pauseReminderAction}
reminderId={reminderId}
/>
)}
{canRestart && (
<ConfirmCard
title="Restart"
description={
isRecurring
? "Activate and re-arm at next occurrence"
: "Activate and fire in ~1 minute"
}
icon={<PlayIcon className="size-4 text-emerald-600 dark:text-emerald-400" />}
accentBg="bg-emerald-500/10"
accentRing="hover:ring-emerald-500/30"
dialogTitle="Restart this reminder?"
dialogBody={
isRecurring
? "The next occurrence will be computed from the recurrence rule and the reminder will fire on schedule."
: "The reminder will become active and fire about a minute from now."
}
confirmLabel="Yes, restart"
confirmVariant="default"
confirmIcon={<PlayIcon />}
action={restartReminderAction}
reminderId={reminderId}
/>
)}
{/* Delete is always available */}
<ConfirmCard
title="Delete"
description="Remove the reminder; history is kept"
icon={<Trash2Icon className="size-4 text-destructive" />}
accentBg="bg-destructive/10"
accentRing="hover:ring-destructive/30"
dialogTitle="Delete this reminder?"
dialogBody="The reminder will be removed. Run history is preserved on the Activity tab."
confirmLabel="Yes, delete"
confirmVariant="destructive"
confirmIcon={<Trash2Icon />}
action={deleteReminderAction}
reminderId={reminderId}
/>
</div>
);
}
interface ConfirmCardProps {
title: string;
description: string;
icon: React.ReactNode;
accentBg: string;
accentRing: string;
dialogTitle: string;
dialogBody: string;
confirmLabel: string;
confirmVariant: "default" | "destructive";
confirmIcon: React.ReactNode;
action: (formData: FormData) => Promise<void>;
reminderId: string;
}
function ConfirmCard(props: ConfirmCardProps) {
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
return (
<Dialog>
<Card className={`relative transition-all hover:shadow-md ${props.accentRing} cursor-pointer`}>
<CardContent className="flex items-center gap-3 py-3 px-4">
<div className={`flex size-9 shrink-0 items-center justify-center rounded-lg ${props.accentBg}`}>
{props.icon}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{props.title}</p>
<p className="text-xs text-muted-foreground truncate">{props.description}</p>
</div>
</CardContent>
<DialogTrigger asChild>
<button
type="button"
aria-label={props.title}
className="absolute inset-0 w-full rounded-xl bg-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
/>
</DialogTrigger>
</Card>
<DialogContent>
<DialogHeader>
<DialogTitle>{props.dialogTitle}</DialogTitle>
<DialogDescription>{props.dialogBody}</DialogDescription>
</DialogHeader>
{error && (
<p className="flex items-center gap-1.5 text-xs text-destructive">
<AlertCircleIcon className="size-3.5 shrink-0" />
{error}
</p>
)}
<DialogFooter showCloseButton>
<form
action={async (fd: FormData) => {
setSubmitting(true);
setError(null);
try {
await props.action(fd);
} catch (e) {
setError(e instanceof Error ? e.message : "Action failed");
setSubmitting(false);
}
}}
>
<input type="hidden" name="reminderId" value={props.reminderId} />
<Button
type="submit"
variant={props.confirmVariant}
size="sm"
disabled={submitting}
className="gap-2"
>
{submitting ? <Loader2Icon className="size-4 animate-spin" /> : props.confirmIcon}
{submitting ? "Working…" : props.confirmLabel}
</Button>
</form>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -1,55 +0,0 @@
"use client";
import { useState } from "react";
import { Trash2Icon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { deleteReminderAction } from "@/actions/reminders";
interface DeleteDialogProps {
reminderId: string;
}
export function DeleteDialog({ reminderId }: DeleteDialogProps) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="destructive" size="sm">
<Trash2Icon />
Delete Reminder
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete this reminder?</DialogTitle>
<DialogDescription>
This action cannot be undone. The reminder and all its run history
will be permanently deleted.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<form action={deleteReminderAction} className="flex gap-2">
<input type="hidden" name="reminderId" value={reminderId} />
<Button
variant="destructive"
size="sm"
type="submit"
>
Delete
</Button>
</form>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -29,7 +29,7 @@ import {
} from "@/components/ui/table";
import { getSeededOperator } from "@/lib/operator";
import { getReminderWithRuns } from "@/lib/queries";
import { DeleteDialog } from "./delete-dialog";
import { ActionsBar } from "./actions-bar";
function formatWhen(date: Date | null, tz: string): string {
if (!date) return "—";
@ -293,9 +293,15 @@ export default async function ReminderDetailPage({ params }: Props) {
)}
</section>
{/* Action footer — Delete only; section cards above handle editing */}
<div className="flex items-center justify-end gap-2 pt-2 border-t">
<DeleteDialog reminderId={reminder.id} />
{/* Lifecycle actions — Pause / Restart / Delete (section cards
above handle editing). */}
<div className="space-y-3 pt-2 border-t">
<h2 className="text-base font-medium tracking-tight">Actions</h2>
<ActionsBar
reminderId={reminder.id}
status={reminder.status}
isRecurring={reminder.scheduleKind === "recurring"}
/>
</div>
</div>
);

View File

@ -6,17 +6,19 @@ import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { getSeededOperator } from "@/lib/operator";
import { listReminders } from "@/lib/queries";
import { listAccounts, listReminders } from "@/lib/queries";
import { describeRecurrence, specFromRrule } from "@/lib/recurrence";
import {
applyReminderFilter,
type SortKey,
type ReminderRow,
} from "@/lib/reminder-filter";
import { ReminderFilterBar } from "@/components/reminder-filter-bar";
import { db } from "@/lib/db";
import { sql } from "drizzle-orm";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type FilterValue = "all" | "active" | "ended" | "paused";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function formatWhen(date: Date | null, tz: string): string {
if (!date) return "—";
return new Intl.DateTimeFormat("en-MY", {
@ -28,9 +30,6 @@ function formatWhen(date: Date | null, tz: string): string {
}).format(new Date(date));
}
// ---------------------------------------------------------------------------
// Status pill
// ---------------------------------------------------------------------------
const STATUS_STYLES: Record<string, string> = {
active:
"bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent",
@ -54,9 +53,6 @@ function StatusPill({ status }: { status: string }) {
);
}
// ---------------------------------------------------------------------------
// Filter tabs
// ---------------------------------------------------------------------------
const FILTER_TABS: { value: FilterValue; label: string }[] = [
{ value: "all", label: "All" },
{ value: "active", label: "Active" },
@ -64,32 +60,103 @@ const FILTER_TABS: { value: FilterValue; label: string }[] = [
{ value: "paused", label: "Paused" },
];
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
const VALID_SORT_KEYS: SortKey[] = [
"scheduled_desc",
"scheduled_asc",
"created_desc",
"name_asc",
];
interface PageProps {
searchParams: Promise<{ filter?: string }>;
searchParams: Promise<{
filter?: string;
q?: string;
accountId?: string;
groupId?: string;
sort?: string;
}>;
}
export default async function RemindersPage({ searchParams }: PageProps) {
const { filter: rawFilter } = await searchParams;
const filter: FilterValue =
rawFilter === "active" || rawFilter === "ended" || rawFilter === "paused"
? rawFilter
const sp = await searchParams;
const status: FilterValue =
sp.filter === "active" || sp.filter === "ended" || sp.filter === "paused"
? sp.filter
: "all";
const sort: SortKey = (VALID_SORT_KEYS as string[]).includes(sp.sort ?? "")
? (sp.sort as SortKey)
: "scheduled_desc";
const op = await getSeededOperator();
const allReminders = await listReminders(op.id);
const tz = op.defaultTimezone ?? "UTC";
const filtered =
filter === "all"
? allReminders
: allReminders.filter((r) => r.status === filter);
// Run the reminder query and the filter-options query in parallel.
const [allReminders, accounts, groupsResult] = await Promise.all([
listReminders(op.id),
listAccounts(op.id),
db.execute(sql`
SELECT wg.id, wg.name, wg.account_id
FROM whatsapp_groups wg
JOIN whatsapp_accounts wa ON wa.id = wg.account_id
WHERE wa.operator_id = ${op.id}
ORDER BY wg.name
`),
]);
const groups = (groupsResult.rows as Array<Record<string, unknown>>).map((g) => ({
id: g.id as string,
name: g.name as string,
accountId: g.account_id as string,
}));
const filterRows: ReminderRow[] = allReminders.map((r) => ({
id: r.id,
name: r.name,
status: r.status,
accountId: r.accountId,
accountLabel: r.accountLabel,
groupIds: r.groupIds,
groupNames: r.groupNames,
firstText: r.firstText,
scheduledAt: r.scheduledAt,
createdAt: r.createdAt,
}));
const filteredIds = new Set(
applyReminderFilter(filterRows, {
q: sp.q,
accountId: sp.accountId,
groupId: sp.groupId,
status,
sort,
}).map((r) => r.id),
);
const sortedFiltered = applyReminderFilter(filterRows, {
q: sp.q,
accountId: sp.accountId,
groupId: sp.groupId,
status,
sort,
});
const visible = sortedFiltered
.map((r) => allReminders.find((row) => row.id === r.id))
.filter((r): r is (typeof allReminders)[number] => Boolean(r));
const tabHref = (value: FilterValue): string => {
const params = new URLSearchParams();
if (value !== "all") params.set("filter", value);
if (sp.q) params.set("q", sp.q);
if (sp.accountId) params.set("accountId", sp.accountId);
if (sp.groupId) params.set("groupId", sp.groupId);
if (sp.sort && sp.sort !== "scheduled_desc") params.set("sort", sp.sort);
const qs = params.toString();
return qs ? `/reminders?${qs}` : "/reminders";
};
const hasAnyFilter = Boolean(sp.q || sp.accountId || sp.groupId);
void filteredIds; // (kept above for clarity; we use sortedFiltered directly)
return (
<div className="px-4 py-6 sm:px-6 sm:py-8 max-w-5xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-semibold tracking-tight">Reminders</h1>
<Button asChild size="sm">
@ -101,24 +168,26 @@ export default async function RemindersPage({ searchParams }: PageProps) {
</Button>
</div>
{/* Filter tabs — URL-driven, no client state */}
<Tabs value={filter}>
<ReminderFilterBar
accounts={accounts.map((a) => ({ id: a.id, label: a.label }))}
groups={groups}
/>
{/* Status tabs — preserve other filter params so flipping tabs doesn't lose them */}
<Tabs value={status}>
<TabsList>
{FILTER_TABS.map(({ value, label }) => (
<TabsTrigger key={value} value={value} asChild>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={(value === "all" ? "/reminders" : `/reminders?filter=${value}`) as any}>
{label}
</Link>
<Link href={tabHref(value) as any}>{label}</Link>
</TabsTrigger>
))}
</TabsList>
</Tabs>
{/* Reminder list */}
{filtered.length > 0 ? (
{visible.length > 0 ? (
<div className="flex flex-col gap-3">
{filtered.map((reminder) => (
{visible.map((reminder) => (
<Link
key={reminder.id}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -127,7 +196,6 @@ export default async function RemindersPage({ searchParams }: PageProps) {
>
<Card className="transition-shadow hover:shadow-md hover:ring-foreground/20">
<CardContent className="flex items-center gap-3 py-3 px-4">
{/* Status + name */}
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<StatusPill status={reminder.status} />
@ -137,10 +205,10 @@ export default async function RemindersPage({ searchParams }: PageProps) {
</div>
<p className="text-xs text-muted-foreground truncate">
{reminder.accountLabel}
{reminder.groupNames && ` · ${reminder.groupNames}`}
</p>
</div>
{/* When + recurrence + group count */}
<div className="shrink-0 text-right space-y-1">
<div className="flex items-center justify-end gap-1 text-xs text-muted-foreground">
<CalendarIcon className="size-3 shrink-0" />
@ -178,14 +246,18 @@ export default async function RemindersPage({ searchParams }: PageProps) {
<BellIcon className="size-10 text-muted-foreground/40" />
<div className="space-y-1">
<p className="text-sm font-medium">
{filter === "all"
{allReminders.length === 0
? "No reminders yet."
: `No ${filter} reminders yet.`}
: hasAnyFilter
? "No reminders match your filters."
: `No ${status} reminders yet.`}
</p>
<p className="text-xs text-muted-foreground">
{allReminders.length === 0
? "Create a reminder to start sending scheduled WhatsApp messages."
: `Reminders in other states aren't shown by this filter.`}
: hasAnyFilter
? "Try clearing the filters or widening your search."
: "Reminders in other states aren't shown by this filter."}
</p>
</div>
{allReminders.length === 0 && (

View File

@ -0,0 +1,191 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { SearchIcon, XIcon } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { SortKey } from "@/lib/reminder-filter";
interface AccountOption {
id: string;
label: string;
}
interface GroupOption {
id: string;
name: string;
accountId: string;
}
interface FilterBarProps {
accounts: AccountOption[];
groups: GroupOption[];
}
const SORT_OPTIONS: Array<{ value: SortKey; label: string }> = [
{ value: "scheduled_desc", label: "Newest first" },
{ value: "scheduled_asc", label: "Oldest first" },
{ value: "created_desc", label: "Recently created" },
{ value: "name_asc", label: "Name A→Z" },
];
/**
* URL-driven filter row for /reminders. The page reads searchParams
* server-side and re-renders with the filtered+sorted dataset; this
* component only writes to the URL.
*
* Search debounces 250ms before pushing so each keystroke doesn't
* trigger a server round-trip. Selects push immediately.
*/
export function ReminderFilterBar({ accounts, groups }: FilterBarProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const initial = {
q: searchParams.get("q") ?? "",
accountId: searchParams.get("accountId") ?? "",
groupId: searchParams.get("groupId") ?? "",
sort: (searchParams.get("sort") ?? "scheduled_desc") as SortKey,
};
const [q, setQ] = useState(initial.q);
// Push the search query to the URL after the user pauses typing.
useEffect(() => {
const t = setTimeout(() => {
const sp = new URLSearchParams(searchParams.toString());
if (q.trim()) sp.set("q", q.trim());
else sp.delete("q");
const next = sp.toString();
const current = searchParams.toString();
if (next !== current) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
router.replace(`${pathname}${next ? `?${next}` : ""}` as any);
}
}, 250);
return () => clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [q]);
function setParam(key: string, value: string) {
const sp = new URLSearchParams(searchParams.toString());
if (value) sp.set(key, value);
else sp.delete(key);
// Clearing accountId also clears the dependent groupId — a group
// belongs to a single account, mixing them produces an empty list.
if (key === "accountId" && !value) sp.delete("groupId");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
router.replace(`${pathname}${sp.toString() ? `?${sp.toString()}` : ""}` as any);
}
const visibleGroups = useMemo(() => {
if (!initial.accountId) return groups;
return groups.filter((g) => g.accountId === initial.accountId);
}, [groups, initial.accountId]);
const hasActiveFilter =
Boolean(q) || Boolean(initial.accountId) || Boolean(initial.groupId);
function clearAll() {
setQ("");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
router.replace(pathname as any);
}
return (
<div className="flex flex-col gap-3">
<div className="relative">
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
<Input
type="search"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Search by name, message, account, or group…"
className="pl-8"
aria-label="Search reminders"
/>
{q && (
<button
type="button"
onClick={() => setQ("")}
aria-label="Clear search"
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<XIcon className="size-3.5" />
</button>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<div className="space-y-1">
<Label htmlFor="filter-account" className="text-xs text-muted-foreground">
Account
</Label>
<select
id="filter-account"
value={initial.accountId}
onChange={(e) => setParam("accountId", e.target.value)}
className="h-8 w-full rounded-lg border border-input bg-transparent px-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<option value="">All accounts</option>
{accounts.map((a) => (
<option key={a.id} value={a.id}>
{a.label}
</option>
))}
</select>
</div>
<div className="space-y-1">
<Label htmlFor="filter-group" className="text-xs text-muted-foreground">
Group
</Label>
<select
id="filter-group"
value={initial.groupId}
onChange={(e) => setParam("groupId", e.target.value)}
disabled={visibleGroups.length === 0}
className="h-8 w-full rounded-lg border border-input bg-transparent px-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
>
<option value="">All groups</option>
{visibleGroups.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
</div>
<div className="space-y-1">
<Label htmlFor="filter-sort" className="text-xs text-muted-foreground">
Sort
</Label>
<select
id="filter-sort"
value={initial.sort}
onChange={(e) => setParam("sort", e.target.value)}
className="h-8 w-full rounded-lg border border-input bg-transparent px-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{SORT_OPTIONS.map(({ value, label }) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
</div>
{hasActiveFilter && (
<div className="flex justify-end">
<button
type="button"
onClick={clearAll}
className="text-xs text-muted-foreground hover:text-foreground underline-offset-2 hover:underline"
>
Clear filters
</button>
</div>
)}
</div>
);
}

View File

@ -104,28 +104,64 @@ export async function getGroup(operatorId: string, groupId: string) {
}
export async function listReminders(operatorId: string) {
// Fetch each reminder along with the snippet text from its first
// message and the comma-separated names+ids of its target groups.
// Both are needed by the /reminders search + filter UI.
const rows = await db.execute(sql`
SELECT
r.id, r.name, r.schedule_kind, r.scheduled_at, r.rrule, r.timezone, r.status,
r.created_at, wa.label as account_label,
(SELECT count(*) FROM reminder_targets rt WHERE rt.reminder_id = r.id) as group_count
r.created_at, r.account_id,
wa.label as account_label,
(SELECT count(*) FROM reminder_targets rt WHERE rt.reminder_id = r.id) as group_count,
(
SELECT COALESCE(string_agg(wg.id::text, ',' ORDER BY rt.position), '')
FROM reminder_targets rt
JOIN whatsapp_groups wg ON wg.id = rt.group_id
WHERE rt.reminder_id = r.id
) as group_ids,
(
SELECT COALESCE(string_agg(wg.name, ' · ' ORDER BY rt.position), '')
FROM reminder_targets rt
JOIN whatsapp_groups wg ON wg.id = rt.group_id
WHERE rt.reminder_id = r.id
) as group_names,
(
SELECT rm.text_content
FROM reminder_messages rm
WHERE rm.reminder_id = r.id
ORDER BY rm.position ASC
LIMIT 1
) as first_text
FROM reminders r
JOIN whatsapp_accounts wa ON wa.id = r.account_id
WHERE wa.operator_id = ${operatorId}
ORDER BY r.scheduled_at DESC NULLS LAST, r.created_at DESC
LIMIT 100
LIMIT 200
`);
// pg returns timestamp columns from raw `db.execute(sql)` as strings,
// not Date instances — coerce so callers (sort comparators, date
// formatters) can call .getTime() / .toLocaleDateString() safely.
const toDate = (v: unknown): Date | null => {
if (v == null) return null;
if (v instanceof Date) return v;
if (typeof v === "string" || typeof v === "number") return new Date(v);
return null;
};
return (rows.rows as Array<Record<string, unknown>>).map((r) => ({
id: r.id as string,
name: r.name as string,
scheduleKind: r.schedule_kind as string,
scheduledAt: r.scheduled_at as Date | null,
scheduledAt: toDate(r.scheduled_at),
rrule: (r.rrule as string | null) ?? null,
timezone: r.timezone as string,
status: r.status as string,
createdAt: r.created_at as Date,
createdAt: toDate(r.created_at) ?? new Date(0),
accountId: r.account_id as string,
accountLabel: r.account_label as string,
groupCount: Number(r.group_count),
groupIds: ((r.group_ids as string | null) ?? "").split(",").filter(Boolean),
groupNames: (r.group_names as string | null) ?? "",
firstText: (r.first_text as string | null) ?? "",
}));
}

View File

@ -0,0 +1,167 @@
import { describe, it, expect } from "vitest";
import {
applyReminderFilter,
type ReminderRow,
} from "./reminder-filter";
const mk = (over: Partial<ReminderRow> = {}): ReminderRow => ({
id: "r-1",
name: "Daily Standup",
status: "active",
accountId: "acc-1",
accountLabel: "Work Phone",
groupIds: ["g-1", "g-2"],
groupNames: "Engineering · Product",
firstText: "Hello team — daily standup at 10am",
scheduledAt: new Date("2026-05-13T02:00:00Z"),
createdAt: new Date("2026-05-01T00:00:00Z"),
...over,
});
describe("applyReminderFilter — search query (q)", () => {
it("empty/missing q returns everything", () => {
const rows = [mk({ id: "a" }), mk({ id: "b", name: "Other" })];
expect(applyReminderFilter(rows, {}).map((r) => r.id)).toEqual(["a", "b"]);
expect(applyReminderFilter(rows, { q: "" }).map((r) => r.id)).toEqual(["a", "b"]);
expect(applyReminderFilter(rows, { q: " " }).map((r) => r.id)).toEqual(["a", "b"]);
});
it("matches against the reminder name (case-insensitive)", () => {
// Wipe the other text fields so we only see hits from the name.
const rows = [
mk({ id: "a", name: "Daily Standup", firstText: "", accountLabel: "", groupNames: "" }),
mk({ id: "b", name: "Lunch", firstText: "", accountLabel: "", groupNames: "" }),
];
expect(applyReminderFilter(rows, { q: "stand" }).map((r) => r.id)).toEqual(["a"]);
expect(applyReminderFilter(rows, { q: "STAND" }).map((r) => r.id)).toEqual(["a"]);
});
it("matches against the first message text", () => {
const rows = [
mk({ id: "a", firstText: "Submit your timesheet" }),
mk({ id: "b", firstText: "Daily standup ping" }),
];
expect(applyReminderFilter(rows, { q: "timesheet" }).map((r) => r.id)).toEqual(["a"]);
});
it("matches against the account label and the joined group names", () => {
const rows = [
mk({ id: "a", accountLabel: "Sales Phone", groupNames: "Acme Corp" }),
mk({ id: "b", accountLabel: "Personal", groupNames: "Family" }),
];
expect(applyReminderFilter(rows, { q: "sales" }).map((r) => r.id)).toEqual(["a"]);
expect(applyReminderFilter(rows, { q: "family" }).map((r) => r.id)).toEqual(["b"]);
});
});
describe("applyReminderFilter — account / group filters", () => {
it("accountId narrows to a single account", () => {
const rows = [
mk({ id: "a", accountId: "acc-1" }),
mk({ id: "b", accountId: "acc-2" }),
];
expect(applyReminderFilter(rows, { accountId: "acc-2" }).map((r) => r.id)).toEqual(["b"]);
});
it("groupId matches if the reminder targets that group", () => {
const rows = [
mk({ id: "a", groupIds: ["g-1"] }),
mk({ id: "b", groupIds: ["g-2", "g-3"] }),
mk({ id: "c", groupIds: [] }),
];
expect(applyReminderFilter(rows, { groupId: "g-2" }).map((r) => r.id)).toEqual(["b"]);
});
it("status='all' or unset includes every status", () => {
const rows = [mk({ id: "a", status: "active" }), mk({ id: "b", status: "ended" })];
expect(applyReminderFilter(rows, { status: "all" }).map((r) => r.id)).toEqual(["a", "b"]);
expect(applyReminderFilter(rows, {}).map((r) => r.id)).toEqual(["a", "b"]);
});
it("status filters to the matching value", () => {
const rows = [
mk({ id: "a", status: "active" }),
mk({ id: "b", status: "ended" }),
mk({ id: "c", status: "paused" }),
];
expect(applyReminderFilter(rows, { status: "paused" }).map((r) => r.id)).toEqual(["c"]);
});
});
describe("applyReminderFilter — sort", () => {
const rows = [
mk({
id: "later",
name: "Z later",
scheduledAt: new Date("2026-06-01T00:00:00Z"),
createdAt: new Date("2026-05-10T00:00:00Z"),
}),
mk({
id: "soon",
name: "A soon",
scheduledAt: new Date("2026-05-15T00:00:00Z"),
createdAt: new Date("2026-05-12T00:00:00Z"),
}),
mk({
id: "noschedule",
name: "M no schedule",
scheduledAt: null,
createdAt: new Date("2026-05-13T00:00:00Z"),
}),
];
it("default order is scheduled_desc — null scheduled goes last", () => {
expect(applyReminderFilter(rows, {}).map((r) => r.id)).toEqual([
"later",
"soon",
"noschedule",
]);
});
it("scheduled_asc reverses the chronological order — null scheduled also goes last", () => {
expect(applyReminderFilter(rows, { sort: "scheduled_asc" }).map((r) => r.id)).toEqual([
"soon",
"later",
"noschedule",
]);
});
it("created_desc orders by createdAt", () => {
expect(applyReminderFilter(rows, { sort: "created_desc" }).map((r) => r.id)).toEqual([
"noschedule",
"soon",
"later",
]);
});
it("name_asc orders alphabetically", () => {
expect(applyReminderFilter(rows, { sort: "name_asc" }).map((r) => r.id)).toEqual([
"soon", // A
"noschedule", // M
"later", // Z
]);
});
});
describe("applyReminderFilter — combined", () => {
it("AND-combines q + accountId + groupId + status", () => {
// firstText/accountLabel/groupNames cleared so the only `q` hits
// come from the name.
const base = { firstText: "", accountLabel: "", groupNames: "" };
const rows = [
mk({ id: "match", name: "Daily ping", accountId: "acc-1", groupIds: ["g-1"], status: "active", ...base }),
mk({ id: "wrong-acc", name: "Daily ping", accountId: "acc-2", groupIds: ["g-1"], status: "active", ...base }),
mk({ id: "wrong-group", name: "Daily ping", accountId: "acc-1", groupIds: ["g-9"], status: "active", ...base }),
mk({ id: "wrong-status", name: "Daily ping", accountId: "acc-1", groupIds: ["g-1"], status: "ended", ...base }),
mk({ id: "wrong-q", name: "Lunch", accountId: "acc-1", groupIds: ["g-1"], status: "active", ...base }),
];
expect(
applyReminderFilter(rows, {
q: "daily",
accountId: "acc-1",
groupId: "g-1",
status: "active",
}).map((r) => r.id),
).toEqual(["match"]);
});
});

View File

@ -0,0 +1,80 @@
/**
* Pure filter / search / sort logic for the /reminders list. Lifted out
* of the page so it's directly unit-testable.
*/
export type SortKey =
| "scheduled_desc" // upcoming/recent first
| "scheduled_asc" // oldest first
| "created_desc"
| "name_asc";
export interface ReminderRow {
id: string;
name: string;
status: string;
accountId: string;
accountLabel: string;
groupIds: string[];
groupNames: string; // pre-joined display string from the SQL
firstText: string;
scheduledAt: Date | null;
createdAt: Date;
}
export interface ReminderFilter {
q?: string;
accountId?: string;
groupId?: string;
status?: string; // "all" | "active" | "ended" | "paused"
sort?: SortKey;
}
/**
* Match a reminder against a free-text query. We search across the
* reminder name, the first message text, the account label, and the
* comma-joined group names. Case-insensitive substring match.
*/
function matchesQuery(r: ReminderRow, q: string): boolean {
if (!q) return true;
const needle = q.toLowerCase();
const haystack = `${r.name} ${r.firstText} ${r.accountLabel} ${r.groupNames}`.toLowerCase();
return haystack.includes(needle);
}
function matchesAccount(r: ReminderRow, accountId?: string): boolean {
if (!accountId) return true;
return r.accountId === accountId;
}
function matchesGroup(r: ReminderRow, groupId?: string): boolean {
if (!groupId) return true;
return r.groupIds.includes(groupId);
}
function matchesStatus(r: ReminderRow, status?: string): boolean {
if (!status || status === "all") return true;
return r.status === status;
}
const sorters: Record<SortKey, (a: ReminderRow, b: ReminderRow) => number> = {
scheduled_desc: (a, b) =>
(b.scheduledAt?.getTime() ?? 0) - (a.scheduledAt?.getTime() ?? 0),
scheduled_asc: (a, b) =>
(a.scheduledAt?.getTime() ?? Infinity) - (b.scheduledAt?.getTime() ?? Infinity),
created_desc: (a, b) => b.createdAt.getTime() - a.createdAt.getTime(),
name_asc: (a, b) => a.name.localeCompare(b.name),
};
export function applyReminderFilter(rows: ReminderRow[], f: ReminderFilter): ReminderRow[] {
const q = (f.q ?? "").trim();
const filtered = rows.filter(
(r) =>
matchesQuery(r, q) &&
matchesAccount(r, f.accountId) &&
matchesGroup(r, f.groupId) &&
matchesStatus(r, f.status),
);
const sortKey: SortKey = f.sort ?? "scheduled_desc";
return filtered.slice().sort(sorters[sortKey]);
}