import Link from "next/link"; import { PlusIcon, BellIcon, CalendarIcon, UsersIcon, RepeatIcon, PauseIcon, PlayIcon, Trash2Icon, } from "lucide-react"; import { DateTime } from "luxon"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent } from "@/components/ui/card"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { PageShell } from "@/components/page-shell"; import { EmptyState } from "@/components/empty-state"; import { getSeededOperator } from "@/lib/operator"; 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 { SwipeableRow } from "@/components/swipeable-row"; import { deleteReminderAction, pauseReminderAction, restartReminderAction, } from "@/actions/reminders"; type FilterValue = "all" | "active" | "inactive" | "paused"; function formatWhen(date: Date | null, tz: string): string { if (!date) return "—"; return new Intl.DateTimeFormat("en-MY", { timeZone: tz, month: "short", day: "numeric", hour: "numeric", minute: "2-digit", }).format(new Date(date)); } const STATUS_STYLES: Record = { active: "bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent", inactive: "bg-slate-200/60 text-slate-500 dark:bg-slate-700/40 dark:text-slate-400 border-transparent", paused: "bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400 border-transparent", failed: "bg-red-500/15 text-red-600 dark:bg-red-500/20 dark:text-red-400 border-transparent", }; /** * Shared shelf-button component for swipeable reminder rows. Wraps a * server action in a tiny form so the row stays a server component; * the page revalidates after the action lands. */ function ReminderShelfButton({ reminderId, label, icon, action, bg, }: { reminderId: string; label: string; icon: React.ReactNode; action: (formData: FormData) => Promise; bg: string; }) { return (
); } function StatusPill({ status }: { status: string }) { const cls = STATUS_STYLES[status] ?? "bg-secondary text-secondary-foreground border-transparent"; const label = status.charAt(0).toUpperCase() + status.slice(1); return ( {label} ); } const FILTER_TABS: { value: FilterValue; label: string }[] = [ { value: "all", label: "All" }, { value: "active", label: "Active" }, { value: "inactive", label: "Inactive" }, { value: "paused", label: "Paused" }, ]; const VALID_SORT_KEYS: SortKey[] = [ "scheduled_desc", "scheduled_asc", "created_desc", "name_asc", ]; interface PageProps { searchParams: Promise<{ filter?: string; q?: string; accountId?: string; sort?: string; }>; } export default async function RemindersPage({ searchParams }: PageProps) { const sp = await searchParams; const status: FilterValue = sp.filter === "active" || sp.filter === "inactive" || sp.filter === "paused" ? sp.filter : "all"; // Sort is now fixed to `created_desc`. Reordering on every status flip // (Restart, Pause, Edit) was causing rows to jump around the list, // which made the swipe gesture feel like the wrong thing happened. // `created_at` never changes so the row stays put. const sort: SortKey = "created_desc"; void VALID_SORT_KEYS; // kept for future use; no longer read from URL const op = await getSeededOperator(); const tz = op.defaultTimezone ?? "UTC"; // Run the reminder query and the filter-options query in parallel. // The Group filter was removed (per user request — search already // matches group names) so we don't need the groups list anymore. const [allReminders, accounts] = await Promise.all([ listReminders(op.id), listAccounts(op.id), ]); 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 sortedFiltered = applyReminderFilter(filterRows, { q: sp.q, accountId: sp.accountId, 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); const qs = params.toString(); return qs ? `/reminders?${qs}` : "/reminders"; }; const hasAnyFilter = Boolean(sp.q || sp.accountId); return ( {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} New Reminder } > ({ id: a.id, label: a.label }))} /> {/* 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} ))} {visible.length > 0 ? ( <>

Swipe a row left to Delete, or right to{" "} {status === "paused" ? "Restart" : "Pause"}.

{visible.map((reminder) => { const canPause = reminder.status === "active"; const canRestart = reminder.status === "paused" || reminder.status === "inactive"; const cardBody = (
{reminder.name}

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

{/* Right meta column. Capped at ~14rem so a long recurrence description ("Every month on days 4, 6, 7, 11, 13, 14 +6 more at 11:32") can't starve the reminder name on the left. min-w-0 + truncate on each span ellipsises overflow inside the cap. Title tooltip preserves the full text on hover. */}
{formatWhen(reminder.scheduledAt, tz)}
{reminder.rrule && reminder.scheduledAt ? (
{describeRecurrence( specFromRrule(reminder.rrule), DateTime.fromJSDate(reminder.scheduledAt).setZone(reminder.timezone), )}
) : null} {reminder.groupCount > 0 && (
{reminder.groupCount}{" "} {reminder.groupCount === 1 ? "group" : "groups"}
)}
); // Right swipe → left shelf → Pause (active) / Restart (paused or // ended). Left swipe → right shelf → Delete. For lifecycle // states with no sensible secondary action (e.g. failed) we // omit the left shelf so the row only swipes one direction. const leftShelf = canPause ? ( } action={pauseReminderAction} bg="bg-amber-500/15 text-amber-700 hover:bg-amber-500/25 dark:bg-amber-500/20 dark:text-amber-400 dark:hover:bg-amber-500/30" /> ) : canRestart ? ( } action={restartReminderAction} bg="bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/25 dark:bg-emerald-500/20 dark:text-emerald-400 dark:hover:bg-emerald-500/30" /> ) : undefined; return ( } action={deleteReminderAction} bg="bg-destructive/15 text-destructive hover:bg-destructive/25" /> } > {cardBody} ); })}
) : ( {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} New Reminder ) : undefined } /> )}
); }