From 50df7fcb1161c5edcde17a933718ef1fa2f8d98e Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sun, 10 May 2026 10:11:46 +0800 Subject: [PATCH] feat(reminders): search + filter + sort on the list, Pause/Restart/Delete on detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /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 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 + + + + + ); +} diff --git a/apps/web/src/app/reminders/[id]/delete-dialog.tsx b/apps/web/src/app/reminders/[id]/delete-dialog.tsx deleted file mode 100644 index 8f6c0b0..0000000 --- a/apps/web/src/app/reminders/[id]/delete-dialog.tsx +++ /dev/null @@ -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 ( - - - - - - - Delete this reminder? - - This action cannot be undone. The reminder and all its run history - will be permanently deleted. - - - -
- - -
-
-
-
- ); -} diff --git a/apps/web/src/app/reminders/[id]/page.tsx b/apps/web/src/app/reminders/[id]/page.tsx index 0ce6c67..1af4c26 100644 --- a/apps/web/src/app/reminders/[id]/page.tsx +++ b/apps/web/src/app/reminders/[id]/page.tsx @@ -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) { )} - {/* Action footer — Delete only; section cards above handle editing */} -
- + {/* Lifecycle actions — Pause / Restart / Delete (section cards + above handle editing). */} +
+

Actions

+
); diff --git a/apps/web/src/app/reminders/page.tsx b/apps/web/src/app/reminders/page.tsx index 6334ca2..2dd84d1 100644 --- a/apps/web/src/app/reminders/page.tsx +++ b/apps/web/src/app/reminders/page.tsx @@ -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 = { 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>).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 (
- {/* Header */}

Reminders

- {/* Filter tabs — URL-driven, no client state */} - + ({ id: a.id, label: a.label }))} + groups={groups} + /> + + {/* Status tabs — preserve other filter params so flipping tabs doesn't lose them */} + {FILTER_TABS.map(({ value, label }) => ( {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} - - {label} - + {label} ))} - {/* Reminder list */} - {filtered.length > 0 ? ( + {visible.length > 0 ? (
- {filtered.map((reminder) => ( + {visible.map((reminder) => ( - {/* Status + name */}
@@ -137,10 +205,10 @@ export default async function RemindersPage({ searchParams }: PageProps) {

{reminder.accountLabel} + {reminder.groupNames && ` · ${reminder.groupNames}`}

- {/* When + recurrence + group count */}
@@ -178,14 +246,18 @@ export default async function RemindersPage({ searchParams }: PageProps) {

- {filter === "all" + {allReminders.length === 0 ? "No reminders yet." - : `No ${filter} reminders yet.`} + : hasAnyFilter + ? "No reminders match your filters." + : `No ${status} reminders yet.`}

{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."}

{allReminders.length === 0 && ( diff --git a/apps/web/src/components/reminder-filter-bar.tsx b/apps/web/src/components/reminder-filter-bar.tsx new file mode 100644 index 0000000..ed27a7e --- /dev/null +++ b/apps/web/src/components/reminder-filter-bar.tsx @@ -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 ( +
+
+ + setQ(e.target.value)} + placeholder="Search by name, message, account, or group…" + className="pl-8" + aria-label="Search reminders" + /> + {q && ( + + )} +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ + {hasActiveFilter && ( +
+ +
+ )} +
+ ); +} diff --git a/apps/web/src/lib/queries.ts b/apps/web/src/lib/queries.ts index 58dd900..fd13033 100644 --- a/apps/web/src/lib/queries.ts +++ b/apps/web/src/lib/queries.ts @@ -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>).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) ?? "", })); } diff --git a/apps/web/src/lib/reminder-filter.test.ts b/apps/web/src/lib/reminder-filter.test.ts new file mode 100644 index 0000000..cbb0624 --- /dev/null +++ b/apps/web/src/lib/reminder-filter.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect } from "vitest"; +import { + applyReminderFilter, + type ReminderRow, +} from "./reminder-filter"; + +const mk = (over: Partial = {}): 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"]); + }); +}); diff --git a/apps/web/src/lib/reminder-filter.ts b/apps/web/src/lib/reminder-filter.ts new file mode 100644 index 0000000..ef6a2e8 --- /dev/null +++ b/apps/web/src/lib/reminder-filter.ts @@ -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 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]); +}