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:
parent
ec57a78853
commit
50df7fcb11
@ -45,6 +45,89 @@ export async function deleteReminderAction(formData: FormData): Promise<void> {
|
|||||||
redirect("/reminders" as any);
|
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
|
const createReminderSchema = z
|
||||||
.object({
|
.object({
|
||||||
accountId: z.string().uuid(),
|
accountId: z.string().uuid(),
|
||||||
|
|||||||
89
apps/web/src/app/reminders/[id]/actions-bar.test.tsx
Normal file
89
apps/web/src/app/reminders/[id]/actions-bar.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
191
apps/web/src/app/reminders/[id]/actions-bar.tsx
Normal file
191
apps/web/src/app/reminders/[id]/actions-bar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -29,7 +29,7 @@ import {
|
|||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { getSeededOperator } from "@/lib/operator";
|
import { getSeededOperator } from "@/lib/operator";
|
||||||
import { getReminderWithRuns } from "@/lib/queries";
|
import { getReminderWithRuns } from "@/lib/queries";
|
||||||
import { DeleteDialog } from "./delete-dialog";
|
import { ActionsBar } from "./actions-bar";
|
||||||
|
|
||||||
function formatWhen(date: Date | null, tz: string): string {
|
function formatWhen(date: Date | null, tz: string): string {
|
||||||
if (!date) return "—";
|
if (!date) return "—";
|
||||||
@ -293,9 +293,15 @@ export default async function ReminderDetailPage({ params }: Props) {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Action footer — Delete only; section cards above handle editing */}
|
{/* Lifecycle actions — Pause / Restart / Delete (section cards
|
||||||
<div className="flex items-center justify-end gap-2 pt-2 border-t">
|
above handle editing). */}
|
||||||
<DeleteDialog reminderId={reminder.id} />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -6,17 +6,19 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { getSeededOperator } from "@/lib/operator";
|
import { getSeededOperator } from "@/lib/operator";
|
||||||
import { listReminders } from "@/lib/queries";
|
import { listAccounts, listReminders } from "@/lib/queries";
|
||||||
import { describeRecurrence, specFromRrule } from "@/lib/recurrence";
|
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";
|
type FilterValue = "all" | "active" | "ended" | "paused";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
function formatWhen(date: Date | null, tz: string): string {
|
function formatWhen(date: Date | null, tz: string): string {
|
||||||
if (!date) return "—";
|
if (!date) return "—";
|
||||||
return new Intl.DateTimeFormat("en-MY", {
|
return new Intl.DateTimeFormat("en-MY", {
|
||||||
@ -28,9 +30,6 @@ function formatWhen(date: Date | null, tz: string): string {
|
|||||||
}).format(new Date(date));
|
}).format(new Date(date));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Status pill
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
const STATUS_STYLES: Record<string, string> = {
|
const STATUS_STYLES: Record<string, string> = {
|
||||||
active:
|
active:
|
||||||
"bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent",
|
"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 }[] = [
|
const FILTER_TABS: { value: FilterValue; label: string }[] = [
|
||||||
{ value: "all", label: "All" },
|
{ value: "all", label: "All" },
|
||||||
{ value: "active", label: "Active" },
|
{ value: "active", label: "Active" },
|
||||||
@ -64,32 +60,103 @@ const FILTER_TABS: { value: FilterValue; label: string }[] = [
|
|||||||
{ value: "paused", label: "Paused" },
|
{ value: "paused", label: "Paused" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
const VALID_SORT_KEYS: SortKey[] = [
|
||||||
// Page
|
"scheduled_desc",
|
||||||
// ---------------------------------------------------------------------------
|
"scheduled_asc",
|
||||||
|
"created_desc",
|
||||||
|
"name_asc",
|
||||||
|
];
|
||||||
|
|
||||||
interface PageProps {
|
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) {
|
export default async function RemindersPage({ searchParams }: PageProps) {
|
||||||
const { filter: rawFilter } = await searchParams;
|
const sp = await searchParams;
|
||||||
const filter: FilterValue =
|
const status: FilterValue =
|
||||||
rawFilter === "active" || rawFilter === "ended" || rawFilter === "paused"
|
sp.filter === "active" || sp.filter === "ended" || sp.filter === "paused"
|
||||||
? rawFilter
|
? sp.filter
|
||||||
: "all";
|
: "all";
|
||||||
|
const sort: SortKey = (VALID_SORT_KEYS as string[]).includes(sp.sort ?? "")
|
||||||
|
? (sp.sort as SortKey)
|
||||||
|
: "scheduled_desc";
|
||||||
|
|
||||||
const op = await getSeededOperator();
|
const op = await getSeededOperator();
|
||||||
const allReminders = await listReminders(op.id);
|
|
||||||
const tz = op.defaultTimezone ?? "UTC";
|
const tz = op.defaultTimezone ?? "UTC";
|
||||||
|
|
||||||
const filtered =
|
// Run the reminder query and the filter-options query in parallel.
|
||||||
filter === "all"
|
const [allReminders, accounts, groupsResult] = await Promise.all([
|
||||||
? allReminders
|
listReminders(op.id),
|
||||||
: allReminders.filter((r) => r.status === filter);
|
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 (
|
return (
|
||||||
<div className="px-4 py-6 sm:px-6 sm:py-8 max-w-5xl mx-auto space-y-6">
|
<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">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Reminders</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">Reminders</h1>
|
||||||
<Button asChild size="sm">
|
<Button asChild size="sm">
|
||||||
@ -101,24 +168,26 @@ export default async function RemindersPage({ searchParams }: PageProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter tabs — URL-driven, no client state */}
|
<ReminderFilterBar
|
||||||
<Tabs value={filter}>
|
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>
|
<TabsList>
|
||||||
{FILTER_TABS.map(({ value, label }) => (
|
{FILTER_TABS.map(({ value, label }) => (
|
||||||
<TabsTrigger key={value} value={value} asChild>
|
<TabsTrigger key={value} value={value} asChild>
|
||||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||||
<Link href={(value === "all" ? "/reminders" : `/reminders?filter=${value}`) as any}>
|
<Link href={tabHref(value) as any}>{label}</Link>
|
||||||
{label}
|
|
||||||
</Link>
|
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
))}
|
))}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{/* Reminder list */}
|
{visible.length > 0 ? (
|
||||||
{filtered.length > 0 ? (
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{filtered.map((reminder) => (
|
{visible.map((reminder) => (
|
||||||
<Link
|
<Link
|
||||||
key={reminder.id}
|
key={reminder.id}
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// 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">
|
<Card className="transition-shadow hover:shadow-md hover:ring-foreground/20">
|
||||||
<CardContent className="flex items-center gap-3 py-3 px-4">
|
<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="min-w-0 flex-1 space-y-1">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<StatusPill status={reminder.status} />
|
<StatusPill status={reminder.status} />
|
||||||
@ -137,10 +205,10 @@ export default async function RemindersPage({ searchParams }: PageProps) {
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground truncate">
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
{reminder.accountLabel}
|
{reminder.accountLabel}
|
||||||
|
{reminder.groupNames && ` · ${reminder.groupNames}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* When + recurrence + group count */}
|
|
||||||
<div className="shrink-0 text-right space-y-1">
|
<div className="shrink-0 text-right space-y-1">
|
||||||
<div className="flex items-center justify-end gap-1 text-xs text-muted-foreground">
|
<div className="flex items-center justify-end gap-1 text-xs text-muted-foreground">
|
||||||
<CalendarIcon className="size-3 shrink-0" />
|
<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" />
|
<BellIcon className="size-10 text-muted-foreground/40" />
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-sm font-medium">
|
<p className="text-sm font-medium">
|
||||||
{filter === "all"
|
{allReminders.length === 0
|
||||||
? "No reminders yet."
|
? "No reminders yet."
|
||||||
: `No ${filter} reminders yet.`}
|
: hasAnyFilter
|
||||||
|
? "No reminders match your filters."
|
||||||
|
: `No ${status} reminders yet.`}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{allReminders.length === 0
|
{allReminders.length === 0
|
||||||
? "Create a reminder to start sending scheduled WhatsApp messages."
|
? "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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{allReminders.length === 0 && (
|
{allReminders.length === 0 && (
|
||||||
|
|||||||
191
apps/web/src/components/reminder-filter-bar.tsx
Normal file
191
apps/web/src/components/reminder-filter-bar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -104,28 +104,64 @@ export async function getGroup(operatorId: string, groupId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function listReminders(operatorId: 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`
|
const rows = await db.execute(sql`
|
||||||
SELECT
|
SELECT
|
||||||
r.id, r.name, r.schedule_kind, r.scheduled_at, r.rrule, r.timezone, r.status,
|
r.id, r.name, r.schedule_kind, r.scheduled_at, r.rrule, r.timezone, r.status,
|
||||||
r.created_at, wa.label as account_label,
|
r.created_at, r.account_id,
|
||||||
(SELECT count(*) FROM reminder_targets rt WHERE rt.reminder_id = r.id) as group_count
|
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
|
FROM reminders r
|
||||||
JOIN whatsapp_accounts wa ON wa.id = r.account_id
|
JOIN whatsapp_accounts wa ON wa.id = r.account_id
|
||||||
WHERE wa.operator_id = ${operatorId}
|
WHERE wa.operator_id = ${operatorId}
|
||||||
ORDER BY r.scheduled_at DESC NULLS LAST, r.created_at DESC
|
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) => ({
|
return (rows.rows as Array<Record<string, unknown>>).map((r) => ({
|
||||||
id: r.id as string,
|
id: r.id as string,
|
||||||
name: r.name as string,
|
name: r.name as string,
|
||||||
scheduleKind: r.schedule_kind 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,
|
rrule: (r.rrule as string | null) ?? null,
|
||||||
timezone: r.timezone as string,
|
timezone: r.timezone as string,
|
||||||
status: r.status 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,
|
accountLabel: r.account_label as string,
|
||||||
groupCount: Number(r.group_count),
|
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) ?? "",
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
167
apps/web/src/lib/reminder-filter.test.ts
Normal file
167
apps/web/src/lib/reminder-filter.test.ts
Normal 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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
80
apps/web/src/lib/reminder-filter.ts
Normal file
80
apps/web/src/lib/reminder-filter.ts
Normal 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]);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user