377 lines
14 KiB
TypeScript
377 lines
14 KiB
TypeScript
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<string, string> = {
|
|
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<void>;
|
|
bg: string;
|
|
}) {
|
|
return (
|
|
<form action={action} className="flex w-full">
|
|
<input type="hidden" name="reminderId" value={reminderId} />
|
|
<button
|
|
type="submit"
|
|
aria-label={label}
|
|
className={`flex h-full w-full flex-col items-center justify-center gap-1 text-xs font-medium ${bg}`}
|
|
>
|
|
{icon}
|
|
{label}
|
|
</button>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<Badge variant="secondary" className={cls}>
|
|
{label}
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<PageShell
|
|
title="Reminders"
|
|
floatingAction={
|
|
<Button
|
|
asChild
|
|
size="sm"
|
|
className="rounded-full shadow-md hover:shadow-lg transition-shadow gap-1.5 sm:h-7 size-12 sm:size-auto sm:px-2.5"
|
|
>
|
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
|
<Link href={"/reminders/new" as any} aria-label="New reminder">
|
|
<PlusIcon className="size-5 sm:size-3.5" />
|
|
<span className="hidden sm:inline">New Reminder</span>
|
|
</Link>
|
|
</Button>
|
|
}
|
|
>
|
|
<ReminderFilterBar
|
|
accounts={accounts.map((a) => ({ id: a.id, label: a.label }))}
|
|
/>
|
|
|
|
{/* Status tabs — preserve other filter params so flipping tabs doesn't lose them */}
|
|
<Tabs value={status}>
|
|
<TabsList className="w-full">
|
|
{FILTER_TABS.map(({ value, label }) => (
|
|
<TabsTrigger key={value} value={value} asChild>
|
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
|
<Link href={tabHref(value) as any}>{label}</Link>
|
|
</TabsTrigger>
|
|
))}
|
|
</TabsList>
|
|
</Tabs>
|
|
|
|
{visible.length > 0 ? (
|
|
<>
|
|
<p className="text-xs text-muted-foreground sm:hidden">
|
|
Swipe a row left to Delete, or right to{" "}
|
|
{status === "paused" ? "Restart" : "Pause"}.
|
|
</p>
|
|
|
|
<div className="flex flex-col gap-3">
|
|
{visible.map((reminder) => {
|
|
const canPause = reminder.status === "active";
|
|
const canRestart =
|
|
reminder.status === "paused" || reminder.status === "inactive";
|
|
const cardBody = (
|
|
<Link
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
href={`/reminders/${reminder.id}` as any}
|
|
className="block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
>
|
|
<Card className="transition-shadow hover:shadow-md hover:ring-foreground/20">
|
|
<CardContent className="flex items-center gap-3 py-3 px-4">
|
|
<div className="min-w-0 flex-1 space-y-1">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<StatusPill status={reminder.status} />
|
|
<span className="text-sm font-medium leading-none truncate">
|
|
{reminder.name}
|
|
</span>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground truncate">
|
|
{reminder.accountLabel}
|
|
{reminder.groupNames && ` · ${reminder.groupNames}`}
|
|
</p>
|
|
</div>
|
|
|
|
{/* 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. */}
|
|
<div className="min-w-0 max-w-[20%] sm:max-w-[5.5rem] text-right space-y-1">
|
|
<div className="flex min-w-0 items-center justify-end gap-1 text-xs text-muted-foreground">
|
|
<CalendarIcon className="size-3 shrink-0" />
|
|
<span className="truncate">
|
|
{formatWhen(reminder.scheduledAt, tz)}
|
|
</span>
|
|
</div>
|
|
{reminder.rrule && reminder.scheduledAt ? (
|
|
<div
|
|
className="flex min-w-0 items-center justify-end gap-1 text-xs text-primary/80"
|
|
title={describeRecurrence(
|
|
specFromRrule(reminder.rrule),
|
|
DateTime.fromJSDate(reminder.scheduledAt).setZone(reminder.timezone),
|
|
)}
|
|
>
|
|
<RepeatIcon className="size-3 shrink-0" />
|
|
<span className="truncate">
|
|
{describeRecurrence(
|
|
specFromRrule(reminder.rrule),
|
|
DateTime.fromJSDate(reminder.scheduledAt).setZone(reminder.timezone),
|
|
)}
|
|
</span>
|
|
</div>
|
|
) : null}
|
|
{reminder.groupCount > 0 && (
|
|
<div className="flex min-w-0 items-center justify-end gap-1 text-xs text-muted-foreground">
|
|
<UsersIcon className="size-3 shrink-0" />
|
|
<span className="truncate">
|
|
{reminder.groupCount}{" "}
|
|
{reminder.groupCount === 1 ? "group" : "groups"}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</Link>
|
|
);
|
|
|
|
// 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 ? (
|
|
<ReminderShelfButton
|
|
reminderId={reminder.id}
|
|
label="Pause"
|
|
icon={<PauseIcon className="size-4" />}
|
|
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 ? (
|
|
<ReminderShelfButton
|
|
reminderId={reminder.id}
|
|
label="Restart"
|
|
icon={<PlayIcon className="size-4" />}
|
|
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 (
|
|
<SwipeableRow
|
|
// Key includes both id and status so a status change
|
|
// (Pause / Restart / Delete result) remounts the row,
|
|
// which resets its swipe offset back to closed. Without
|
|
// this, clicking a shelf button leaves the shelf open
|
|
// even after the row's content updates.
|
|
key={`${reminder.id}-${reminder.status}`}
|
|
leftActions={leftShelf}
|
|
rightActions={
|
|
<ReminderShelfButton
|
|
reminderId={reminder.id}
|
|
label="Delete"
|
|
icon={<Trash2Icon className="size-4" />}
|
|
action={deleteReminderAction}
|
|
bg="bg-destructive/15 text-destructive hover:bg-destructive/25"
|
|
/>
|
|
}
|
|
>
|
|
{cardBody}
|
|
</SwipeableRow>
|
|
);
|
|
})}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<EmptyState
|
|
icon={BellIcon}
|
|
title={
|
|
allReminders.length === 0
|
|
? "No reminders yet."
|
|
: hasAnyFilter
|
|
? "No reminders match your filters."
|
|
: `No ${status} reminders yet.`
|
|
}
|
|
description={
|
|
allReminders.length === 0
|
|
? "Create a reminder to start sending scheduled WhatsApp messages."
|
|
: hasAnyFilter
|
|
? "Try clearing the filters or widening your search."
|
|
: "Reminders in other states aren't shown by this filter."
|
|
}
|
|
action={
|
|
allReminders.length === 0 ? (
|
|
<Button asChild size="sm">
|
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
|
<Link href={"/reminders/new" as any}>
|
|
<PlusIcon />
|
|
New Reminder
|
|
</Link>
|
|
</Button>
|
|
) : undefined
|
|
}
|
|
/>
|
|
)}
|
|
</PageShell>
|
|
);
|
|
}
|