yiekheng f4da1dd510 fix(web): halve right-meta column cap (max-w 20% / 5.5rem)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:16:40 +08:00

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>
);
}