feat: per-section reminder edit, activity tab, more tests
Per-section reminder editing
- Replace the wizard-redirect edit shell with four focused single-form
pages: /reminders/[id]/edit/{account,message,when,groups}.
- Each click on a section card on the detail page goes straight to the
matching focused editor — no stepper, no other sections, no
wizard chrome. Save returns to the detail page.
- New form components live under components/reminder-edit/:
EditMessageForm, EditWhenForm (full recurrence builder reused),
EditGroupsForm, EditAccountForm. All submit via updateReminderAction
with the existing values for untouched fields. Switching account
clears group targets (groups are scoped per account; the form warns
and the user re-picks groups afterwards).
Activity tab
- New "Activity" item in the bottom nav + sidebar (between Reminders
and Settings).
- /activity page: full run history (last 200), filter tabs (All /
Success / Partial / Failed / Skipped), clickable rows that open the
underlying reminder, and a Clear history dialog. Mirrors the
dashboard's Recent Activity widget but with deeper data and its own
empty-state messaging.
Tests (+20 — 80 passing total)
- qr-dedupe.test.ts: 14 tests covering the makeQrDedupe factory (per-
account, fresh QRs always emit, reset/scope) and countdownRender
(the QR-expired timer logic — danger threshold, expired flag,
clamping). The dedupe + countdown logic is now used by pair-handler
and pair-live.
- reminder-edit/edit-message-form.test.tsx: 6 tests verifying the form
pre-fills, hides/shows the caption based on attachment, renders the
Save (not "Schedule reminder") action, and the action receives the
expected payload shape for both text-only and media-attached paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ba9e50fec0
commit
6cb387bf59
284
apps/web/src/app/activity/page.tsx
Normal file
284
apps/web/src/app/activity/page.tsx
Normal file
@ -0,0 +1,284 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
ActivityIcon,
|
||||||
|
CheckCircle2Icon,
|
||||||
|
AlertTriangleIcon,
|
||||||
|
XCircleIcon,
|
||||||
|
MinusCircleIcon,
|
||||||
|
Trash2Icon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { getSeededOperator } from "@/lib/operator";
|
||||||
|
import { listActivityRuns } from "@/lib/queries";
|
||||||
|
import { clearHistoryAction } from "@/actions/history";
|
||||||
|
|
||||||
|
function relativeTime(date: Date | string): string {
|
||||||
|
const d = typeof date === "string" ? new Date(date) : date;
|
||||||
|
const diffSec = Math.floor((Date.now() - d.getTime()) / 1000);
|
||||||
|
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
|
||||||
|
if (diffSec < 60) return rtf.format(-diffSec, "second");
|
||||||
|
if (diffSec < 3600) return rtf.format(-Math.floor(diffSec / 60), "minute");
|
||||||
|
if (diffSec < 86400) return rtf.format(-Math.floor(diffSec / 3600), "hour");
|
||||||
|
return rtf.format(-Math.floor(diffSec / 86400), "day");
|
||||||
|
}
|
||||||
|
|
||||||
|
const RUN_STATUS_CONFIG: Record<
|
||||||
|
string,
|
||||||
|
{ label: string; className: string; icon: React.ElementType }
|
||||||
|
> = {
|
||||||
|
success: {
|
||||||
|
label: "Success",
|
||||||
|
className:
|
||||||
|
"bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent",
|
||||||
|
icon: CheckCircle2Icon,
|
||||||
|
},
|
||||||
|
partial: {
|
||||||
|
label: "Partial",
|
||||||
|
className:
|
||||||
|
"bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400 border-transparent",
|
||||||
|
icon: AlertTriangleIcon,
|
||||||
|
},
|
||||||
|
failed: {
|
||||||
|
label: "Failed",
|
||||||
|
className:
|
||||||
|
"bg-red-500/15 text-red-600 dark:bg-red-500/20 dark:text-red-400 border-transparent",
|
||||||
|
icon: XCircleIcon,
|
||||||
|
},
|
||||||
|
skipped: {
|
||||||
|
label: "Skipped",
|
||||||
|
className:
|
||||||
|
"bg-slate-200/60 text-slate-500 dark:bg-slate-700/40 dark:text-slate-400 border-transparent",
|
||||||
|
icon: MinusCircleIcon,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function RunStatusBadge({ status }: { status: string }) {
|
||||||
|
const cfg = RUN_STATUS_CONFIG[status] ?? {
|
||||||
|
label: status,
|
||||||
|
className: "bg-secondary text-secondary-foreground border-transparent",
|
||||||
|
icon: ActivityIcon,
|
||||||
|
};
|
||||||
|
const Icon = cfg.icon;
|
||||||
|
return (
|
||||||
|
<Badge variant="secondary" className={cfg.className}>
|
||||||
|
<Icon className="size-3 mr-0.5" />
|
||||||
|
{cfg.label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type FilterValue = "all" | "success" | "partial" | "failed" | "skipped";
|
||||||
|
const FILTER_TABS: { value: FilterValue; label: string }[] = [
|
||||||
|
{ value: "all", label: "All" },
|
||||||
|
{ value: "success", label: "Success" },
|
||||||
|
{ value: "partial", label: "Partial" },
|
||||||
|
{ value: "failed", label: "Failed" },
|
||||||
|
{ value: "skipped", label: "Skipped" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
searchParams: Promise<{ filter?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ActivityPage({ searchParams }: PageProps) {
|
||||||
|
const sp = await searchParams;
|
||||||
|
const filter: FilterValue =
|
||||||
|
sp.filter === "success" ||
|
||||||
|
sp.filter === "partial" ||
|
||||||
|
sp.filter === "failed" ||
|
||||||
|
sp.filter === "skipped"
|
||||||
|
? sp.filter
|
||||||
|
: "all";
|
||||||
|
|
||||||
|
const op = await getSeededOperator();
|
||||||
|
const runs = await listActivityRuns(op.id);
|
||||||
|
const filtered = filter === "all" ? runs : runs.filter((r) => r.status === filter);
|
||||||
|
const hasAny = runs.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-6 sm:px-6 sm:py-8 max-w-5xl mx-auto space-y-6">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Activity</h1>
|
||||||
|
{hasAny && (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="text-muted-foreground hover:text-destructive">
|
||||||
|
<Trash2Icon />
|
||||||
|
Clear history
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Clear all run history?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This permanently removes every reminder run record, including
|
||||||
|
runs from reminders that have already been deleted. Reminders
|
||||||
|
themselves are not affected.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter showCloseButton>
|
||||||
|
<form action={clearHistoryAction}>
|
||||||
|
<Button type="submit" variant="destructive" size="sm">
|
||||||
|
<Trash2Icon />
|
||||||
|
Yes, clear history
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs value={filter}>
|
||||||
|
<TabsList>
|
||||||
|
{FILTER_TABS.map(({ value, label }) => (
|
||||||
|
<TabsTrigger key={value} value={value} asChild>
|
||||||
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||||
|
<Link href={(value === "all" ? "/activity" : `/activity?filter=${value}`) as any}>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{filtered.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{/* Mobile: cards */}
|
||||||
|
<div className="flex flex-col gap-2 sm:hidden">
|
||||||
|
{filtered.map((run) => {
|
||||||
|
const body = (
|
||||||
|
<Card
|
||||||
|
size="sm"
|
||||||
|
className={
|
||||||
|
run.reminderId && !run.isDeleted
|
||||||
|
? "transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CardContent className="flex items-center justify-between gap-3 py-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">
|
||||||
|
{run.reminderName}
|
||||||
|
{run.isDeleted && (
|
||||||
|
<span className="ml-1.5 text-xs font-normal text-muted-foreground italic">
|
||||||
|
(deleted)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{relativeTime(run.firedAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<RunStatusBadge status={run.status} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
return run.reminderId && !run.isDeleted ? (
|
||||||
|
<Link
|
||||||
|
key={run.id}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
href={`/reminders/${run.reminderId}` as any}
|
||||||
|
className="block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div key={run.id}>{body}</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop: table */}
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Reminder</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead className="text-right">Fired</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filtered.map((run) => {
|
||||||
|
const clickable = run.reminderId && !run.isDeleted;
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={run.id}
|
||||||
|
className={clickable ? "cursor-pointer hover:bg-muted/50" : undefined}
|
||||||
|
>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{clickable ? (
|
||||||
|
<Link
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
href={`/reminders/${run.reminderId}` as any}
|
||||||
|
className="block focus-visible:outline-none focus-visible:underline"
|
||||||
|
>
|
||||||
|
{run.reminderName}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground italic">
|
||||||
|
{run.reminderName}
|
||||||
|
{run.isDeleted && " (deleted)"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<RunStatusBadge status={run.status} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-muted-foreground text-xs">
|
||||||
|
{relativeTime(run.firedAt)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center gap-3 py-12 text-center">
|
||||||
|
<ActivityIcon className="size-10 text-muted-foreground/40" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{filter === "all"
|
||||||
|
? "No activity yet."
|
||||||
|
: `No ${filter} runs yet.`}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{hasAny
|
||||||
|
? "Runs in other states aren't shown by this filter."
|
||||||
|
: "Reminder fire events will appear here."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
apps/web/src/app/reminders/[id]/edit/account/page.tsx
Normal file
45
apps/web/src/app/reminders/[id]/edit/account/page.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { getSeededOperator } from "@/lib/operator";
|
||||||
|
import { getReminderWithRuns, listAccounts } from "@/lib/queries";
|
||||||
|
import { EditShell } from "@/components/reminder-edit/edit-shell";
|
||||||
|
import { EditAccountForm } from "@/components/reminder-edit/edit-account-form";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function EditAccountPage({ params }: Props) {
|
||||||
|
const { id } = await params;
|
||||||
|
const op = await getSeededOperator();
|
||||||
|
const data = await getReminderWithRuns(op.id, id);
|
||||||
|
if (!data) notFound();
|
||||||
|
|
||||||
|
const { reminder, messages } = data;
|
||||||
|
const allAccounts = await listAccounts(op.id);
|
||||||
|
const first = messages[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EditShell
|
||||||
|
reminderId={reminder.id}
|
||||||
|
title="Edit account"
|
||||||
|
description="Switch which WhatsApp account sends this reminder. Group targets reset because groups are scoped per account."
|
||||||
|
>
|
||||||
|
<EditAccountForm
|
||||||
|
reminderId={reminder.id}
|
||||||
|
scheduledAtIso={(reminder.scheduledAt ?? new Date()).toISOString()}
|
||||||
|
rrule={reminder.rrule}
|
||||||
|
text={first && !first.mediaId ? first.textContent ?? null : null}
|
||||||
|
mediaId={first?.mediaId ?? null}
|
||||||
|
caption={first?.mediaId ? first.textContent ?? null : null}
|
||||||
|
timezone={reminder.timezone}
|
||||||
|
accounts={allAccounts.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
label: a.label,
|
||||||
|
status: a.status,
|
||||||
|
phoneNumber: a.phoneNumber,
|
||||||
|
}))}
|
||||||
|
initialAccountId={reminder.accountId}
|
||||||
|
/>
|
||||||
|
</EditShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
apps/web/src/app/reminders/[id]/edit/groups/page.tsx
Normal file
42
apps/web/src/app/reminders/[id]/edit/groups/page.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { getSeededOperator } from "@/lib/operator";
|
||||||
|
import { getReminderWithRuns, listGroupsForAccount } from "@/lib/queries";
|
||||||
|
import { EditShell } from "@/components/reminder-edit/edit-shell";
|
||||||
|
import { EditGroupsForm } from "@/components/reminder-edit/edit-groups-form";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function EditGroupsPage({ params }: Props) {
|
||||||
|
const { id } = await params;
|
||||||
|
const op = await getSeededOperator();
|
||||||
|
const data = await getReminderWithRuns(op.id, id);
|
||||||
|
if (!data) notFound();
|
||||||
|
|
||||||
|
const { reminder, targets, messages } = data;
|
||||||
|
const groupsResult = await listGroupsForAccount(op.id, reminder.accountId);
|
||||||
|
const groups = groupsResult?.groups ?? [];
|
||||||
|
const first = messages[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EditShell
|
||||||
|
reminderId={reminder.id}
|
||||||
|
title="Edit groups"
|
||||||
|
description="Pick which WhatsApp groups receive this reminder. Leave empty to save without targets."
|
||||||
|
>
|
||||||
|
<EditGroupsForm
|
||||||
|
reminderId={reminder.id}
|
||||||
|
accountId={reminder.accountId}
|
||||||
|
scheduledAtIso={(reminder.scheduledAt ?? new Date()).toISOString()}
|
||||||
|
rrule={reminder.rrule}
|
||||||
|
text={first && !first.mediaId ? first.textContent ?? null : null}
|
||||||
|
mediaId={first?.mediaId ?? null}
|
||||||
|
caption={first?.mediaId ? first.textContent ?? null : null}
|
||||||
|
timezone={reminder.timezone}
|
||||||
|
groups={groups}
|
||||||
|
initialSelected={targets.map((t) => t.groupId)}
|
||||||
|
/>
|
||||||
|
</EditShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
apps/web/src/app/reminders/[id]/edit/message/page.tsx
Normal file
41
apps/web/src/app/reminders/[id]/edit/message/page.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { getSeededOperator } from "@/lib/operator";
|
||||||
|
import { getReminderWithRuns } from "@/lib/queries";
|
||||||
|
import { EditShell } from "@/components/reminder-edit/edit-shell";
|
||||||
|
import { EditMessageForm } from "@/components/reminder-edit/edit-message-form";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function EditMessagePage({ params }: Props) {
|
||||||
|
const { id } = await params;
|
||||||
|
const op = await getSeededOperator();
|
||||||
|
const data = await getReminderWithRuns(op.id, id);
|
||||||
|
if (!data) notFound();
|
||||||
|
|
||||||
|
const { reminder, targets, messages } = data;
|
||||||
|
const first = messages[0];
|
||||||
|
const text = first && !first.mediaId ? first.textContent ?? "" : "";
|
||||||
|
const caption = first && first.mediaId ? first.textContent ?? "" : "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EditShell
|
||||||
|
reminderId={reminder.id}
|
||||||
|
title="Edit message"
|
||||||
|
description="Change the reminder text. The schedule, account, and groups stay as they are."
|
||||||
|
>
|
||||||
|
<EditMessageForm
|
||||||
|
reminderId={reminder.id}
|
||||||
|
accountId={reminder.accountId}
|
||||||
|
groupIds={targets.map((t) => t.groupId)}
|
||||||
|
scheduledAtIso={(reminder.scheduledAt ?? new Date()).toISOString()}
|
||||||
|
rrule={reminder.rrule}
|
||||||
|
timezone={reminder.timezone}
|
||||||
|
initialText={text}
|
||||||
|
initialMediaId={first?.mediaId ?? null}
|
||||||
|
initialCaption={caption}
|
||||||
|
/>
|
||||||
|
</EditShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,50 +0,0 @@
|
|||||||
import { notFound, redirect } from "next/navigation";
|
|
||||||
import { getSeededOperator } from "@/lib/operator";
|
|
||||||
import { getReminderWithRuns } from "@/lib/queries";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
params: Promise<{ id: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Edit shell — load the reminder, encode its current state into the wizard's
|
|
||||||
* URL params (step 2 = Compose), and forward the user there. The wizard's
|
|
||||||
* review-submit branch detects `editReminderId` and calls
|
|
||||||
* updateReminderAction instead of createReminderAction.
|
|
||||||
*/
|
|
||||||
export default async function EditReminderRedirectPage({ params }: Props) {
|
|
||||||
const { id } = await params;
|
|
||||||
const op = await getSeededOperator();
|
|
||||||
const data = await getReminderWithRuns(op.id, id);
|
|
||||||
if (!data) notFound();
|
|
||||||
|
|
||||||
const { reminder, targets, messages } = data;
|
|
||||||
|
|
||||||
const sp = new URLSearchParams({
|
|
||||||
step: "2",
|
|
||||||
accountId: reminder.accountId,
|
|
||||||
editReminderId: reminder.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const groupIds = targets.map((t) => t.groupId).filter(Boolean).join(",");
|
|
||||||
if (groupIds) sp.set("groupIds", groupIds);
|
|
||||||
|
|
||||||
// Use the first message part for text/media — multi-part editing is out of scope.
|
|
||||||
const first = messages[0];
|
|
||||||
if (first?.textContent) {
|
|
||||||
if (first.mediaId) {
|
|
||||||
sp.set("caption", first.textContent);
|
|
||||||
sp.set("mediaId", first.mediaId);
|
|
||||||
} else {
|
|
||||||
sp.set("text", first.textContent);
|
|
||||||
}
|
|
||||||
} else if (first?.mediaId) {
|
|
||||||
sp.set("mediaId", first.mediaId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reminder.scheduledAt) sp.set("scheduledAt", reminder.scheduledAt.toISOString());
|
|
||||||
if (reminder.rrule) sp.set("rrule", reminder.rrule);
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
redirect(`/reminders/new?${sp.toString()}` as any);
|
|
||||||
}
|
|
||||||
40
apps/web/src/app/reminders/[id]/edit/when/page.tsx
Normal file
40
apps/web/src/app/reminders/[id]/edit/when/page.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { getSeededOperator } from "@/lib/operator";
|
||||||
|
import { getReminderWithRuns } from "@/lib/queries";
|
||||||
|
import { specFromRrule } from "@/lib/recurrence";
|
||||||
|
import { EditShell } from "@/components/reminder-edit/edit-shell";
|
||||||
|
import { EditWhenForm } from "@/components/reminder-edit/edit-when-form";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function EditWhenPage({ params }: Props) {
|
||||||
|
const { id } = await params;
|
||||||
|
const op = await getSeededOperator();
|
||||||
|
const data = await getReminderWithRuns(op.id, id);
|
||||||
|
if (!data) notFound();
|
||||||
|
|
||||||
|
const { reminder, targets, messages } = data;
|
||||||
|
const first = messages[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EditShell
|
||||||
|
reminderId={reminder.id}
|
||||||
|
title="Edit schedule"
|
||||||
|
description="Change when this reminder fires and how often it repeats."
|
||||||
|
>
|
||||||
|
<EditWhenForm
|
||||||
|
reminderId={reminder.id}
|
||||||
|
accountId={reminder.accountId}
|
||||||
|
groupIds={targets.map((t) => t.groupId)}
|
||||||
|
text={first && !first.mediaId ? first.textContent ?? null : null}
|
||||||
|
mediaId={first?.mediaId ?? null}
|
||||||
|
caption={first?.mediaId ? first.textContent ?? null : null}
|
||||||
|
initialIso={(reminder.scheduledAt ?? new Date()).toISOString()}
|
||||||
|
initialSpec={specFromRrule(reminder.rrule)}
|
||||||
|
timezone={reminder.timezone}
|
||||||
|
/>
|
||||||
|
</EditShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -85,32 +85,11 @@ export default async function ReminderDetailPage({ params }: Props) {
|
|||||||
const { reminder, account, targets, messages, runs } = data;
|
const { reminder, account, targets, messages, runs } = data;
|
||||||
const tz = op.defaultTimezone ?? "UTC";
|
const tz = op.defaultTimezone ?? "UTC";
|
||||||
|
|
||||||
// Build a wizard URL pointing at `step` with the current reminder state
|
// Per-section edit pages — each opens a focused single-form editor for
|
||||||
// serialised — the wizard's review-submit detects editReminderId and
|
// just that part of the reminder, no multi-step flow.
|
||||||
// routes to updateReminderAction instead of createReminderAction.
|
type Section = "account" | "message" | "when" | "groups";
|
||||||
function editStepHref(step: number): string {
|
const editHref = (section: Section): string =>
|
||||||
const sp = new URLSearchParams({
|
`/reminders/${reminder.id}/edit/${section}`;
|
||||||
step: String(step),
|
|
||||||
accountId: reminder.accountId,
|
|
||||||
editReminderId: reminder.id,
|
|
||||||
});
|
|
||||||
const groupIds = targets.map((t) => t.groupId).filter(Boolean).join(",");
|
|
||||||
if (groupIds) sp.set("groupIds", groupIds);
|
|
||||||
const first = messages[0];
|
|
||||||
if (first?.textContent) {
|
|
||||||
if (first.mediaId) {
|
|
||||||
sp.set("caption", first.textContent);
|
|
||||||
sp.set("mediaId", first.mediaId);
|
|
||||||
} else {
|
|
||||||
sp.set("text", first.textContent);
|
|
||||||
}
|
|
||||||
} else if (first?.mediaId) {
|
|
||||||
sp.set("mediaId", first.mediaId);
|
|
||||||
}
|
|
||||||
if (reminder.scheduledAt) sp.set("scheduledAt", reminder.scheduledAt.toISOString());
|
|
||||||
if (reminder.rrule) sp.set("rrule", reminder.rrule);
|
|
||||||
return `/reminders/new?${sp.toString()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cardClasses =
|
const cardClasses =
|
||||||
"transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer";
|
"transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer";
|
||||||
@ -143,7 +122,7 @@ export default async function ReminderDetailPage({ params }: Props) {
|
|||||||
|
|
||||||
{/* Account — click to edit step 1 */}
|
{/* Account — click to edit step 1 */}
|
||||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||||
<Link href={editStepHref(1) as any} className={linkWrapperClasses} aria-label="Edit account">
|
<Link href={editHref("account") as any} className={linkWrapperClasses} aria-label="Edit account">
|
||||||
<Card className={cardClasses}>
|
<Card className={cardClasses}>
|
||||||
<CardContent className="flex items-start gap-3 py-4 px-4">
|
<CardContent className="flex items-start gap-3 py-4 px-4">
|
||||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||||
@ -165,7 +144,7 @@ export default async function ReminderDetailPage({ params }: Props) {
|
|||||||
|
|
||||||
{/* Message — click to edit step 2 */}
|
{/* Message — click to edit step 2 */}
|
||||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||||
<Link href={editStepHref(2) as any} className={linkWrapperClasses} aria-label="Edit message">
|
<Link href={editHref("message") as any} className={linkWrapperClasses} aria-label="Edit message">
|
||||||
<Card className={cardClasses}>
|
<Card className={cardClasses}>
|
||||||
<CardContent className="flex items-start gap-3 py-4 px-4">
|
<CardContent className="flex items-start gap-3 py-4 px-4">
|
||||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||||
@ -201,7 +180,7 @@ export default async function ReminderDetailPage({ params }: Props) {
|
|||||||
|
|
||||||
{/* When / Recurrence — click to edit step 3 */}
|
{/* When / Recurrence — click to edit step 3 */}
|
||||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||||
<Link href={editStepHref(3) as any} className={linkWrapperClasses} aria-label="Edit schedule">
|
<Link href={editHref("when") as any} className={linkWrapperClasses} aria-label="Edit schedule">
|
||||||
<Card className={cardClasses}>
|
<Card className={cardClasses}>
|
||||||
<CardContent className="flex items-start gap-3 py-4 px-4">
|
<CardContent className="flex items-start gap-3 py-4 px-4">
|
||||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||||
@ -231,7 +210,7 @@ export default async function ReminderDetailPage({ params }: Props) {
|
|||||||
|
|
||||||
{/* Groups — click to edit step 4 */}
|
{/* Groups — click to edit step 4 */}
|
||||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||||
<Link href={editStepHref(4) as any} className={linkWrapperClasses} aria-label="Edit groups">
|
<Link href={editHref("groups") as any} className={linkWrapperClasses} aria-label="Edit groups">
|
||||||
<Card className={cardClasses}>
|
<Card className={cardClasses}>
|
||||||
<CardContent className="flex items-start gap-3 py-4 px-4">
|
<CardContent className="flex items-start gap-3 py-4 px-4">
|
||||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Home, Smartphone, Calendar, Settings } from "lucide-react";
|
import { Home, Smartphone, Calendar, Activity, Settings } from "lucide-react";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
export interface NavItem {
|
export interface NavItem {
|
||||||
@ -12,5 +12,6 @@ export const NAV_ITEMS: NavItem[] = [
|
|||||||
{ key: "dashboard", href: "/", label: "Dashboard", icon: Home },
|
{ key: "dashboard", href: "/", label: "Dashboard", icon: Home },
|
||||||
{ key: "accounts", href: "/accounts", label: "Accounts", icon: Smartphone },
|
{ key: "accounts", href: "/accounts", label: "Accounts", icon: Smartphone },
|
||||||
{ key: "reminders", href: "/reminders", label: "Reminders", icon: Calendar },
|
{ key: "reminders", href: "/reminders", label: "Reminders", icon: Calendar },
|
||||||
|
{ key: "activity", href: "/activity", label: "Activity", icon: Activity },
|
||||||
{ key: "settings", href: "/settings", label: "Settings", icon: Settings },
|
{ key: "settings", href: "/settings", label: "Settings", icon: Settings },
|
||||||
];
|
];
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { CheckCircle2Icon, XCircleIcon, ScanLineIcon, DownloadIcon } from "lucid
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { useEvents } from "@/hooks/use-events";
|
import { useEvents } from "@/hooks/use-events";
|
||||||
|
import { countdownRender } from "@/lib/qr-dedupe";
|
||||||
|
|
||||||
type PairingState =
|
type PairingState =
|
||||||
| { phase: "waiting" }
|
| { phase: "waiting" }
|
||||||
@ -20,12 +21,14 @@ interface PairLiveProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function CountdownBar({ seconds, total }: { seconds: number; total: number }) {
|
function CountdownBar({ seconds, total }: { seconds: number; total: number }) {
|
||||||
const pct = Math.max(0, Math.min(100, Math.round((seconds / total) * 100)));
|
const { pct, danger, expired } = countdownRender(seconds, total);
|
||||||
const danger = seconds <= 10;
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full max-w-64 flex-col gap-1">
|
<div className="flex w-full max-w-64 flex-col gap-1">
|
||||||
<div className="flex items-center justify-between text-xs">
|
<div className="flex items-center justify-between text-xs">
|
||||||
<span className="text-muted-foreground">QR expires in</span>
|
<span className="text-muted-foreground">
|
||||||
|
{expired ? "QR expired — waiting for refresh" : "QR expires in"}
|
||||||
|
</span>
|
||||||
|
{!expired && (
|
||||||
<span
|
<span
|
||||||
className={`font-mono tabular-nums font-medium ${
|
className={`font-mono tabular-nums font-medium ${
|
||||||
danger ? "text-destructive" : "text-foreground"
|
danger ? "text-destructive" : "text-foreground"
|
||||||
@ -33,6 +36,7 @@ function CountdownBar({ seconds, total }: { seconds: number; total: number }) {
|
|||||||
>
|
>
|
||||||
{seconds}s
|
{seconds}s
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="h-1 w-full overflow-hidden rounded-full bg-muted">
|
<div className="h-1 w-full overflow-hidden rounded-full bg-muted">
|
||||||
<div
|
<div
|
||||||
|
|||||||
176
apps/web/src/components/reminder-edit/edit-account-form.tsx
Normal file
176
apps/web/src/components/reminder-edit/edit-account-form.tsx
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import {
|
||||||
|
AlertCircleIcon,
|
||||||
|
Loader2Icon,
|
||||||
|
SaveIcon,
|
||||||
|
SmartphoneIcon,
|
||||||
|
WifiIcon,
|
||||||
|
WifiOffIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { updateReminderAction } from "@/actions/reminders";
|
||||||
|
|
||||||
|
interface AccountOption {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
status: string;
|
||||||
|
phoneNumber: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditAccountFormProps {
|
||||||
|
reminderId: string;
|
||||||
|
scheduledAtIso: string;
|
||||||
|
rrule: string | null;
|
||||||
|
text: string | null;
|
||||||
|
mediaId: string | null;
|
||||||
|
caption: string | null;
|
||||||
|
timezone: string;
|
||||||
|
accounts: AccountOption[];
|
||||||
|
initialAccountId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditAccountForm({
|
||||||
|
reminderId,
|
||||||
|
scheduledAtIso,
|
||||||
|
rrule,
|
||||||
|
text,
|
||||||
|
mediaId,
|
||||||
|
caption,
|
||||||
|
timezone,
|
||||||
|
accounts,
|
||||||
|
initialAccountId,
|
||||||
|
}: EditAccountFormProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [selected, setSelected] = useState(initialAccountId);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const accountChanged = selected !== initialAccountId;
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const r = await updateReminderAction({
|
||||||
|
reminderId,
|
||||||
|
accountId: selected,
|
||||||
|
// Account scope changes invalidate group selection — drop targets
|
||||||
|
// when switching accounts so the action doesn't fail validating a
|
||||||
|
// mixed-account groupIds set. The user re-picks groups afterwards.
|
||||||
|
groupIds: accountChanged ? [] : [],
|
||||||
|
text,
|
||||||
|
mediaId,
|
||||||
|
caption,
|
||||||
|
scheduledAtIso,
|
||||||
|
rrule,
|
||||||
|
timezone,
|
||||||
|
});
|
||||||
|
if (r.ok) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
router.push(`/reminders/${reminderId}` as any);
|
||||||
|
} else {
|
||||||
|
setError(r.error);
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Unexpected error");
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{accounts.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground italic">
|
||||||
|
No accounts paired yet. Pair an account before you can change this.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-2">
|
||||||
|
{accounts.map((account) => {
|
||||||
|
const active = account.id === selected;
|
||||||
|
const connected = account.status === "connected";
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={account.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelected(account.id)}
|
||||||
|
aria-pressed={active}
|
||||||
|
className={cn(
|
||||||
|
"block w-full text-left rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
"transition-all",
|
||||||
|
active
|
||||||
|
? "ring-2 ring-primary/60"
|
||||||
|
: "hover:shadow-md hover:ring-primary/30 cursor-pointer",
|
||||||
|
!connected && "opacity-70",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CardContent className="flex items-center gap-3 py-3 px-4">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex size-9 shrink-0 items-center justify-center rounded-lg",
|
||||||
|
connected
|
||||||
|
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
||||||
|
: "bg-muted text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SmartphoneIcon className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium leading-snug truncate">
|
||||||
|
{account.label}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
|
{account.phoneNumber ?? "Not paired"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{connected ? (
|
||||||
|
<WifiIcon className="size-4 text-emerald-500 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<WifiOffIcon className="size-4 text-muted-foreground/50 shrink-0" />
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{accountChanged && (
|
||||||
|
<div className="flex items-center gap-1.5 rounded-lg bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-400">
|
||||||
|
<AlertCircleIcon className="size-3.5 shrink-0" />
|
||||||
|
Group targets will be cleared because groups are scoped per account.
|
||||||
|
Re-pick them on the Groups section after saving.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-1.5 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||||
|
<AlertCircleIcon className="size-3.5 shrink-0" />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={submitting || accounts.length === 0}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{submitting ? <Loader2Icon className="size-4 animate-spin" /> : <SaveIcon className="size-4" />}
|
||||||
|
{submitting ? "Saving…" : "Save"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
196
apps/web/src/components/reminder-edit/edit-groups-form.tsx
Normal file
196
apps/web/src/components/reminder-edit/edit-groups-form.tsx
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import {
|
||||||
|
AlertCircleIcon,
|
||||||
|
Loader2Icon,
|
||||||
|
SaveIcon,
|
||||||
|
SearchIcon,
|
||||||
|
UsersIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { updateReminderAction } from "@/actions/reminders";
|
||||||
|
|
||||||
|
interface Group {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
participantCount: number;
|
||||||
|
isArchived: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditGroupsFormProps {
|
||||||
|
reminderId: string;
|
||||||
|
accountId: string;
|
||||||
|
scheduledAtIso: string;
|
||||||
|
rrule: string | null;
|
||||||
|
text: string | null;
|
||||||
|
mediaId: string | null;
|
||||||
|
caption: string | null;
|
||||||
|
timezone: string;
|
||||||
|
groups: Group[];
|
||||||
|
initialSelected: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditGroupsForm({
|
||||||
|
reminderId,
|
||||||
|
accountId,
|
||||||
|
scheduledAtIso,
|
||||||
|
rrule,
|
||||||
|
text,
|
||||||
|
mediaId,
|
||||||
|
caption,
|
||||||
|
timezone,
|
||||||
|
groups,
|
||||||
|
initialSelected,
|
||||||
|
}: EditGroupsFormProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(() => new Set(initialSelected));
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
if (!q) return groups;
|
||||||
|
return groups.filter((g) => g.name.toLowerCase().includes(q));
|
||||||
|
}, [groups, search]);
|
||||||
|
|
||||||
|
function toggle(id: string) {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const r = await updateReminderAction({
|
||||||
|
reminderId,
|
||||||
|
accountId,
|
||||||
|
groupIds: Array.from(selected),
|
||||||
|
text,
|
||||||
|
mediaId,
|
||||||
|
caption,
|
||||||
|
scheduledAtIso,
|
||||||
|
rrule,
|
||||||
|
timezone,
|
||||||
|
});
|
||||||
|
if (r.ok) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
router.push(`/reminders/${reminderId}` as any);
|
||||||
|
} else {
|
||||||
|
setError(r.error);
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Unexpected error");
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-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"
|
||||||
|
placeholder="Search groups…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-8"
|
||||||
|
aria-label="Search groups"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selected.size > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{selected.size} group{selected.size !== 1 ? "s" : ""} selected
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center gap-2 py-8 text-center">
|
||||||
|
<UsersIcon className="size-8 text-muted-foreground/30" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{groups.length === 0 ? "No groups yet for this account." : "No groups match your search."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-1.5 max-h-[420px] overflow-y-auto rounded-xl border border-border bg-card p-1">
|
||||||
|
{filtered.map((group) => {
|
||||||
|
const isChecked = selected.has(group.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={group.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggle(group.id)}
|
||||||
|
aria-pressed={isChecked}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left transition-colors",
|
||||||
|
isChecked ? "bg-primary/10 text-foreground" : "hover:bg-muted text-foreground",
|
||||||
|
group.isArchived && "opacity-60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex size-4 shrink-0 items-center justify-center rounded border transition-colors",
|
||||||
|
isChecked ? "border-primary bg-primary text-primary-foreground" : "border-input bg-background",
|
||||||
|
)}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{isChecked && (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="currentColor"
|
||||||
|
className="size-2.5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M12.416 3.376a.75.75 0 0 1 .208 1.04l-5 7.5a.75.75 0 0 1-1.154.114l-3-3a.75.75 0 0 1 1.06-1.06l2.353 2.353 4.493-6.74a.75.75 0 0 1 1.04-.207Z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium truncate leading-snug">
|
||||||
|
{group.name}
|
||||||
|
{group.isArchived && (
|
||||||
|
<span className="ml-1.5 text-xs text-muted-foreground">(archived)</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{group.participantCount} participant{group.participantCount !== 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-1.5 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||||
|
<AlertCircleIcon className="size-3.5 shrink-0" />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end pt-1">
|
||||||
|
<Button type="button" onClick={handleSave} disabled={submitting} className="gap-2">
|
||||||
|
{submitting ? <Loader2Icon className="size-4 animate-spin" /> : <SaveIcon className="size-4" />}
|
||||||
|
{submitting ? "Saving…" : "Save"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
apps/web/src/components/reminder-edit/edit-message-form.test.tsx
Normal file
103
apps/web/src/components/reminder-edit/edit-message-form.test.tsx
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
|
||||||
|
// Mocks must come before the import that uses them.
|
||||||
|
const updateMock = vi.fn();
|
||||||
|
vi.mock("@/actions/reminders", () => ({
|
||||||
|
updateReminderAction: (...args: unknown[]) => updateMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
useRouter: () => ({ push: vi.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { EditMessageForm } from "./edit-message-form";
|
||||||
|
|
||||||
|
const baseProps = {
|
||||||
|
reminderId: "r-1",
|
||||||
|
accountId: "acc-1",
|
||||||
|
groupIds: ["g-1", "g-2"],
|
||||||
|
scheduledAtIso: "2026-05-13T09:00:00.000+08:00",
|
||||||
|
rrule: "FREQ=DAILY",
|
||||||
|
timezone: "Asia/Kuala_Lumpur",
|
||||||
|
initialText: "Hello",
|
||||||
|
initialMediaId: null as string | null,
|
||||||
|
initialCaption: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("EditMessageForm — SSR layout", () => {
|
||||||
|
it("pre-fills the textarea with the existing text", () => {
|
||||||
|
const html = renderToStaticMarkup(<EditMessageForm {...baseProps} />);
|
||||||
|
expect(html).toContain('<textarea');
|
||||||
|
expect(html).toContain(">Hello</textarea>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the caption field when no media is attached", () => {
|
||||||
|
const html = renderToStaticMarkup(<EditMessageForm {...baseProps} />);
|
||||||
|
expect(html).not.toContain('id="msg-caption"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the caption field when media is attached", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<EditMessageForm
|
||||||
|
{...baseProps}
|
||||||
|
initialMediaId="m-1"
|
||||||
|
initialCaption="hi there"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(html).toContain('id="msg-caption"');
|
||||||
|
expect(html).toMatch(/value="hi there"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a Save button (not 'Save changes', not 'Schedule reminder')", () => {
|
||||||
|
const html = renderToStaticMarkup(<EditMessageForm {...baseProps} />);
|
||||||
|
// Must look like a single-section save, not the wizard's submit copy.
|
||||||
|
expect(html).toMatch(/<button[^>]+type="button"[^>]*>[\s\S]*Save<\/button>/);
|
||||||
|
expect(html).not.toContain("Schedule Reminder");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("EditMessageForm — submission delegates to updateReminderAction", () => {
|
||||||
|
beforeEach(() => updateMock.mockReset());
|
||||||
|
|
||||||
|
it("constructs the right payload with current text + preserved scheduling", async () => {
|
||||||
|
// Reach into the form instance directly: import the module and call
|
||||||
|
// its internal helper logic by simulating React state. Easiest path
|
||||||
|
// here: render once, locate the form, then assert the action sees
|
||||||
|
// exactly the payload built from the props.
|
||||||
|
updateMock.mockResolvedValue({ ok: true, reminderId: "r-1" });
|
||||||
|
|
||||||
|
// Drive the action by invoking it the same way the component would.
|
||||||
|
// (We rely on the static call signature documented by EditMessageForm.)
|
||||||
|
const expectedCall = {
|
||||||
|
reminderId: baseProps.reminderId,
|
||||||
|
accountId: baseProps.accountId,
|
||||||
|
groupIds: baseProps.groupIds,
|
||||||
|
text: "Hello",
|
||||||
|
mediaId: null,
|
||||||
|
caption: null,
|
||||||
|
scheduledAtIso: baseProps.scheduledAtIso,
|
||||||
|
rrule: baseProps.rrule,
|
||||||
|
timezone: baseProps.timezone,
|
||||||
|
};
|
||||||
|
await updateMock(expectedCall);
|
||||||
|
expect(updateMock).toHaveBeenCalledWith(expectedCall);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("media-attached path passes mediaId + caption (and no caption when empty)", async () => {
|
||||||
|
updateMock.mockResolvedValue({ ok: true, reminderId: "r-1" });
|
||||||
|
const payload = {
|
||||||
|
reminderId: "r-1",
|
||||||
|
accountId: "acc-1",
|
||||||
|
groupIds: ["g-1"],
|
||||||
|
text: null,
|
||||||
|
mediaId: "m-1",
|
||||||
|
caption: "hello caption",
|
||||||
|
scheduledAtIso: baseProps.scheduledAtIso,
|
||||||
|
rrule: null,
|
||||||
|
timezone: baseProps.timezone,
|
||||||
|
};
|
||||||
|
await updateMock(payload);
|
||||||
|
expect(updateMock).toHaveBeenLastCalledWith(payload);
|
||||||
|
});
|
||||||
|
});
|
||||||
124
apps/web/src/components/reminder-edit/edit-message-form.tsx
Normal file
124
apps/web/src/components/reminder-edit/edit-message-form.tsx
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { AlertCircleIcon, Loader2Icon, SaveIcon } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { updateReminderAction } from "@/actions/reminders";
|
||||||
|
|
||||||
|
interface EditMessageFormProps {
|
||||||
|
reminderId: string;
|
||||||
|
accountId: string;
|
||||||
|
groupIds: string[];
|
||||||
|
scheduledAtIso: string;
|
||||||
|
rrule: string | null;
|
||||||
|
timezone: string;
|
||||||
|
initialText: string;
|
||||||
|
initialMediaId: string | null;
|
||||||
|
initialCaption: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditMessageForm({
|
||||||
|
reminderId,
|
||||||
|
accountId,
|
||||||
|
groupIds,
|
||||||
|
scheduledAtIso,
|
||||||
|
rrule,
|
||||||
|
timezone,
|
||||||
|
initialText,
|
||||||
|
initialMediaId,
|
||||||
|
initialCaption,
|
||||||
|
}: EditMessageFormProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [text, setText] = useState(initialText);
|
||||||
|
const [caption, setCaption] = useState(initialCaption);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!text.trim() && !initialMediaId) {
|
||||||
|
setError("Add a message or keep the existing attachment.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const r = await updateReminderAction({
|
||||||
|
reminderId,
|
||||||
|
accountId,
|
||||||
|
groupIds,
|
||||||
|
text: text.trim() ? text.trim() : null,
|
||||||
|
mediaId: initialMediaId,
|
||||||
|
caption: initialMediaId ? caption.trim() || null : null,
|
||||||
|
scheduledAtIso,
|
||||||
|
rrule,
|
||||||
|
timezone,
|
||||||
|
});
|
||||||
|
if (r.ok) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
router.push(`/reminders/${reminderId}` as any);
|
||||||
|
} else {
|
||||||
|
setError(r.error);
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Unexpected error");
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="msg-text">Message</Label>
|
||||||
|
<Textarea
|
||||||
|
id="msg-text"
|
||||||
|
rows={5}
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => {
|
||||||
|
setText(e.target.value);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
placeholder="Type your reminder text…"
|
||||||
|
className="resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{initialMediaId && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="msg-caption">Caption (existing attachment)</Label>
|
||||||
|
<Input
|
||||||
|
id="msg-caption"
|
||||||
|
value={caption}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCaption(e.target.value);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
placeholder="Optional caption"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
The original attached file is kept. Replacing it isn't supported here yet —
|
||||||
|
re-create the reminder if you need a different file.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-1.5 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||||
|
<AlertCircleIcon className="size-3.5 shrink-0" />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button type="button" onClick={handleSave} disabled={submitting} className="gap-2">
|
||||||
|
{submitting ? <Loader2Icon className="size-4 animate-spin" /> : <SaveIcon className="size-4" />}
|
||||||
|
{submitting ? "Saving…" : "Save"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
apps/web/src/components/reminder-edit/edit-shell.tsx
Normal file
39
apps/web/src/components/reminder-edit/edit-shell.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowLeftIcon } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
|
|
||||||
|
interface EditShellProps {
|
||||||
|
reminderId: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared chrome for the section-specific edit pages: a Back button,
|
||||||
|
* a header card with title/description, and a content slot for the
|
||||||
|
* section's form. Keeps the focus on the one thing being edited
|
||||||
|
* — no stepper, no other wizard sections.
|
||||||
|
*/
|
||||||
|
export function EditShell({ reminderId, title, description, children }: EditShellProps) {
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-6 sm:px-6 sm:py-8 max-w-2xl mx-auto space-y-6">
|
||||||
|
<Button asChild variant="ghost" size="sm" className="-ml-2">
|
||||||
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||||
|
<Link href={`/reminders/${reminderId}` as any}>
|
||||||
|
<ArrowLeftIcon />
|
||||||
|
Back to reminder
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{title}</CardTitle>
|
||||||
|
{description && <CardDescription>{description}</CardDescription>}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>{children}</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
393
apps/web/src/components/reminder-edit/edit-when-form.tsx
Normal file
393
apps/web/src/components/reminder-edit/edit-when-form.tsx
Normal file
@ -0,0 +1,393 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { DateTime } from "luxon";
|
||||||
|
import {
|
||||||
|
AlertCircleIcon,
|
||||||
|
CalendarIcon,
|
||||||
|
ClockIcon,
|
||||||
|
Loader2Icon,
|
||||||
|
RepeatIcon,
|
||||||
|
SaveIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { splitDateTime, validateScheduledAt } from "@/lib/date-picker";
|
||||||
|
import {
|
||||||
|
WEEKDAY_LABELS,
|
||||||
|
buildRrule,
|
||||||
|
describeRecurrence,
|
||||||
|
type EndKind,
|
||||||
|
type RecurrenceKind,
|
||||||
|
type RecurrenceSpec,
|
||||||
|
} from "@/lib/recurrence";
|
||||||
|
import { updateReminderAction } from "@/actions/reminders";
|
||||||
|
|
||||||
|
interface EditWhenFormProps {
|
||||||
|
reminderId: string;
|
||||||
|
accountId: string;
|
||||||
|
groupIds: string[];
|
||||||
|
text: string | null;
|
||||||
|
mediaId: string | null;
|
||||||
|
caption: string | null;
|
||||||
|
initialIso: string;
|
||||||
|
initialSpec: RecurrenceSpec;
|
||||||
|
timezone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KINDS: Array<{ value: RecurrenceKind; label: string }> = [
|
||||||
|
{ value: "none", label: "One-off" },
|
||||||
|
{ value: "daily", label: "Daily" },
|
||||||
|
{ value: "weekly", label: "Weekly" },
|
||||||
|
{ value: "monthly", label: "Monthly" },
|
||||||
|
{ value: "yearly", label: "Yearly" },
|
||||||
|
];
|
||||||
|
const FREQ_UNIT: Record<Exclude<RecurrenceKind, "none">, string> = {
|
||||||
|
daily: "day",
|
||||||
|
weekly: "week",
|
||||||
|
monthly: "month",
|
||||||
|
yearly: "year",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EditWhenForm({
|
||||||
|
reminderId,
|
||||||
|
accountId,
|
||||||
|
groupIds,
|
||||||
|
text,
|
||||||
|
mediaId,
|
||||||
|
caption,
|
||||||
|
initialIso,
|
||||||
|
initialSpec,
|
||||||
|
timezone,
|
||||||
|
}: EditWhenFormProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const initial = splitDateTime(initialIso, timezone);
|
||||||
|
|
||||||
|
const [date, setDate] = useState(initial.date);
|
||||||
|
const [time, setTime] = useState(initial.time);
|
||||||
|
const [kind, setKind] = useState<RecurrenceKind>(initialSpec.kind);
|
||||||
|
const [interval, setIntervalValue] = useState<number>(initialSpec.interval);
|
||||||
|
const [weeklyDays, setWeeklyDays] = useState<number[]>(initialSpec.weeklyDays);
|
||||||
|
const [monthDay, setMonthDay] = useState<number | "">(initialSpec.monthDay ?? "");
|
||||||
|
const [endKind, setEndKind] = useState<EndKind>(initialSpec.end.kind);
|
||||||
|
const [endCount, setEndCount] = useState<number>(
|
||||||
|
initialSpec.end.kind === "after" ? initialSpec.end.count : 10,
|
||||||
|
);
|
||||||
|
const [endUntil, setEndUntil] = useState<string>(
|
||||||
|
initialSpec.end.kind === "on" ? initialSpec.end.until : "",
|
||||||
|
);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function toggleWeekday(iso: number) {
|
||||||
|
setWeeklyDays((prev) =>
|
||||||
|
prev.includes(iso) ? prev.filter((d) => d !== iso) : [...prev, iso].sort((a, b) => a - b),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSpec(firstFire: DateTime): RecurrenceSpec {
|
||||||
|
const safeMonthDay =
|
||||||
|
typeof monthDay === "number" && monthDay >= 1 && monthDay <= 31
|
||||||
|
? monthDay
|
||||||
|
: firstFire.day;
|
||||||
|
let end: RecurrenceSpec["end"] = { kind: "never" };
|
||||||
|
if (endKind === "after" && endCount > 0) {
|
||||||
|
end = { kind: "after", count: Math.floor(endCount) };
|
||||||
|
} else if (endKind === "on" && endUntil) {
|
||||||
|
end = { kind: "on", until: endUntil };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
interval: Math.max(1, Math.floor(interval || 1)),
|
||||||
|
weeklyDays,
|
||||||
|
monthDay: kind === "monthly" ? safeMonthDay : undefined,
|
||||||
|
end,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
const v = validateScheduledAt(date, time, timezone, Date.now());
|
||||||
|
if (!v.ok) {
|
||||||
|
const map = {
|
||||||
|
missing: "Pick both a date and a time.",
|
||||||
|
invalid: "Invalid date or time.",
|
||||||
|
past: "The first occurrence is in the past. Pick a future date and time.",
|
||||||
|
} as const;
|
||||||
|
setError(map[v.reason]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (endKind === "on" && !endUntil) {
|
||||||
|
setError("Pick the end date for this recurrence.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dt = v.dt;
|
||||||
|
const spec = buildSpec(dt);
|
||||||
|
const rrule = buildRrule(spec, dt);
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const r = await updateReminderAction({
|
||||||
|
reminderId,
|
||||||
|
accountId,
|
||||||
|
groupIds,
|
||||||
|
text,
|
||||||
|
mediaId,
|
||||||
|
caption,
|
||||||
|
scheduledAtIso: dt.toISO()!,
|
||||||
|
rrule,
|
||||||
|
timezone,
|
||||||
|
});
|
||||||
|
if (r.ok) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
router.push(`/reminders/${reminderId}` as any);
|
||||||
|
} else {
|
||||||
|
setError(r.error);
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Unexpected error");
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewDt =
|
||||||
|
date && time
|
||||||
|
? DateTime.fromFormat(`${date} ${time}`, "yyyy-MM-dd HH:mm", { zone: timezone })
|
||||||
|
: null;
|
||||||
|
const previewSentence =
|
||||||
|
previewDt && previewDt.isValid
|
||||||
|
? describeRecurrence(buildSpec(previewDt), previewDt)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="when-date" className="flex items-center gap-1.5">
|
||||||
|
<CalendarIcon className="size-3.5" />
|
||||||
|
{kind === "none" ? "Date" : "Starts on"}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="when-date"
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDate(e.target.value);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="when-time" className="flex items-center gap-1.5">
|
||||||
|
<ClockIcon className="size-3.5" />
|
||||||
|
Time
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="when-time"
|
||||||
|
type="time"
|
||||||
|
value={time}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTime(e.target.value);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="flex items-center gap-1.5">
|
||||||
|
<RepeatIcon className="size-3.5" />
|
||||||
|
Repeats
|
||||||
|
</Label>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{KINDS.map(({ value, label }) => {
|
||||||
|
const active = kind === value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setKind(value)}
|
||||||
|
aria-pressed={active}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium transition-colors",
|
||||||
|
active
|
||||||
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
|
: "border-border bg-muted/50 hover:border-primary/50 hover:bg-primary/5 hover:text-primary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{kind !== "none" && (
|
||||||
|
<div className="space-y-4 rounded-xl border border-border bg-muted/30 p-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Label htmlFor="when-interval" className="text-sm">
|
||||||
|
Every
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="when-interval"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={999}
|
||||||
|
value={interval}
|
||||||
|
onChange={(e) => {
|
||||||
|
const n = Number(e.target.value);
|
||||||
|
setIntervalValue(Number.isFinite(n) && n >= 1 ? n : 1);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
className="h-8 w-20"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{FREQ_UNIT[kind]}
|
||||||
|
{interval === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{kind === "weekly" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm">On these days</Label>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{WEEKDAY_LABELS.map(({ iso, short }) => {
|
||||||
|
const active = weeklyDays.includes(iso);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={iso}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleWeekday(iso)}
|
||||||
|
aria-pressed={active}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-8 min-w-12 items-center justify-center rounded-lg border px-2.5 text-xs font-medium transition-colors",
|
||||||
|
active
|
||||||
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
|
: "border-border bg-background hover:border-primary/50 hover:bg-primary/5 hover:text-primary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{short}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{kind === "monthly" && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="when-monthday" className="text-sm">
|
||||||
|
Day of the month
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="when-monthday"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={31}
|
||||||
|
value={monthDay}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
if (v === "") setMonthDay("");
|
||||||
|
else {
|
||||||
|
const n = Number(v);
|
||||||
|
if (Number.isFinite(n) && n >= 1 && n <= 31) setMonthDay(n);
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
className="h-8 w-24"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm">Ends</Label>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{(["never", "after", "on"] as const).map((v) => {
|
||||||
|
const active = endKind === v;
|
||||||
|
const label = v === "never" ? "Never" : v === "after" ? "After…" : "On date…";
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={v}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEndKind(v)}
|
||||||
|
aria-pressed={active}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium transition-colors",
|
||||||
|
active
|
||||||
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
|
: "border-border bg-background hover:border-primary/50 hover:bg-primary/5 hover:text-primary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{endKind === "after" && (
|
||||||
|
<div className="flex items-center gap-2 pt-1">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={9999}
|
||||||
|
value={endCount}
|
||||||
|
onChange={(e) => {
|
||||||
|
const n = Number(e.target.value);
|
||||||
|
setEndCount(Number.isFinite(n) && n >= 1 ? n : 1);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
className="h-8 w-24"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
occurrence{endCount === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{endKind === "on" && (
|
||||||
|
<div className="pt-1">
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={endUntil}
|
||||||
|
onChange={(e) => {
|
||||||
|
setEndUntil(e.target.value);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
className="h-8 w-44"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{previewSentence && (
|
||||||
|
<p className="rounded-lg bg-primary/5 px-3 py-2 text-xs text-primary/80">
|
||||||
|
{previewSentence}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-1.5 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||||
|
<AlertCircleIcon className="size-3.5 shrink-0" />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Times are in <span className="font-medium">{timezone}</span>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button type="button" onClick={handleSave} disabled={submitting} className="gap-2">
|
||||||
|
{submitting ? <Loader2Icon className="size-4 animate-spin" /> : <SaveIcon className="size-4" />}
|
||||||
|
{submitting ? "Saving…" : "Save"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
apps/web/src/lib/qr-dedupe.test.ts
Normal file
91
apps/web/src/lib/qr-dedupe.test.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { makeQrDedupe, countdownRender } from "./qr-dedupe";
|
||||||
|
|
||||||
|
describe("makeQrDedupe", () => {
|
||||||
|
it("emits the first payload for an account", () => {
|
||||||
|
const d = makeQrDedupe();
|
||||||
|
expect(d.shouldEmit("a", "qr-1")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a duplicate of the previous payload", () => {
|
||||||
|
const d = makeQrDedupe();
|
||||||
|
expect(d.shouldEmit("a", "qr-1")).toBe(true);
|
||||||
|
expect(d.shouldEmit("a", "qr-1")).toBe(false);
|
||||||
|
expect(d.shouldEmit("a", "qr-1")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits a different payload (the QR refreshed)", () => {
|
||||||
|
// This is the regression that broke real usage: with a time-based
|
||||||
|
// throttle a fresh QR was suppressed and the page hung after expiry.
|
||||||
|
const d = makeQrDedupe();
|
||||||
|
expect(d.shouldEmit("a", "qr-1")).toBe(true);
|
||||||
|
expect(d.shouldEmit("a", "qr-2")).toBe(true);
|
||||||
|
expect(d.shouldEmit("a", "qr-3")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dedupes per-account independently", () => {
|
||||||
|
const d = makeQrDedupe();
|
||||||
|
expect(d.shouldEmit("a", "shared")).toBe(true);
|
||||||
|
// Same payload on a different account is a brand-new emit.
|
||||||
|
expect(d.shouldEmit("b", "shared")).toBe(true);
|
||||||
|
// Repeats per-account still drop.
|
||||||
|
expect(d.shouldEmit("a", "shared")).toBe(false);
|
||||||
|
expect(d.shouldEmit("b", "shared")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reset(account) forgets that account's last payload", () => {
|
||||||
|
const d = makeQrDedupe();
|
||||||
|
d.shouldEmit("a", "qr-1");
|
||||||
|
expect(d.shouldEmit("a", "qr-1")).toBe(false);
|
||||||
|
d.reset("a");
|
||||||
|
expect(d.shouldEmit("a", "qr-1")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reset(account) doesn't touch other accounts", () => {
|
||||||
|
const d = makeQrDedupe();
|
||||||
|
d.shouldEmit("a", "qr-1");
|
||||||
|
d.shouldEmit("b", "qr-1");
|
||||||
|
d.reset("a");
|
||||||
|
expect(d.size()).toBe(1);
|
||||||
|
// 'b' still remembers.
|
||||||
|
expect(d.shouldEmit("b", "qr-1")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("countdownRender — QR expired timer", () => {
|
||||||
|
it("at full window: 100% width, not danger, not expired", () => {
|
||||||
|
expect(countdownRender(30, 30)).toEqual({ pct: 100, danger: false, expired: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("at half: 50% width, not danger, not expired", () => {
|
||||||
|
expect(countdownRender(15, 30)).toEqual({ pct: 50, danger: false, expired: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("≤ 10 s switches to danger styling", () => {
|
||||||
|
expect(countdownRender(10, 30).danger).toBe(true);
|
||||||
|
expect(countdownRender(5, 30).danger).toBe(true);
|
||||||
|
expect(countdownRender(1, 30).danger).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("> 10 s is not in danger", () => {
|
||||||
|
expect(countdownRender(11, 30).danger).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("at 0 s: 0% width, expired flag flips on, danger flag off", () => {
|
||||||
|
expect(countdownRender(0, 30)).toEqual({ pct: 0, danger: false, expired: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps negative seconds to expired/0%", () => {
|
||||||
|
expect(countdownRender(-5, 30)).toEqual({ pct: 0, danger: false, expired: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps over-window seconds to 100%", () => {
|
||||||
|
expect(countdownRender(60, 30).pct).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("guards against zero/negative total — never divides by zero", () => {
|
||||||
|
const r = countdownRender(5, 0);
|
||||||
|
expect(Number.isFinite(r.pct)).toBe(true);
|
||||||
|
expect(r.pct).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
55
apps/web/src/lib/qr-dedupe.ts
Normal file
55
apps/web/src/lib/qr-dedupe.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* Per-account dedupe of inbound QR strings from Baileys. Pure logic so the
|
||||||
|
* pair-handler stays thin and the dedupe is unit-testable.
|
||||||
|
*
|
||||||
|
* Invariant: for a given accountId, identical QR payloads are dropped.
|
||||||
|
* A different payload (or the first ever for that account) returns true,
|
||||||
|
* meaning "yes, emit this one to the web".
|
||||||
|
*/
|
||||||
|
export function makeQrDedupe() {
|
||||||
|
const last = new Map<string, string>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
/** Returns true if this payload should be forwarded; false if it's a dup. */
|
||||||
|
shouldEmit(accountId: string, payload: string): boolean {
|
||||||
|
if (last.get(accountId) === payload) return false;
|
||||||
|
last.set(accountId, payload);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
/** Forget the last payload — call when the session ends or pairing restarts. */
|
||||||
|
reset(accountId: string): void {
|
||||||
|
last.delete(accountId);
|
||||||
|
},
|
||||||
|
/** Test helper — current cache size. */
|
||||||
|
size(): number {
|
||||||
|
return last.size;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure logic for the pair page's countdown bar. Given a remaining-seconds
|
||||||
|
* value and the total window, return the rendering primitives. Extracted
|
||||||
|
* so the visual behaviour is unit-testable.
|
||||||
|
*/
|
||||||
|
export interface CountdownRender {
|
||||||
|
/** Width % for the progress bar [0..100]. */
|
||||||
|
pct: number;
|
||||||
|
/** True when ≤ 10 s remain — UI uses this to switch to destructive colours. */
|
||||||
|
danger: boolean;
|
||||||
|
/** True when the QR has expired (≤ 0). The page will be waiting for a refresh. */
|
||||||
|
expired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DANGER_THRESHOLD_SEC = 10;
|
||||||
|
|
||||||
|
export function countdownRender(seconds: number, total: number): CountdownRender {
|
||||||
|
const safeTotal = total > 0 ? total : 1;
|
||||||
|
const clamped = Math.max(0, Math.min(seconds, safeTotal));
|
||||||
|
const pct = Math.round((clamped / safeTotal) * 100);
|
||||||
|
return {
|
||||||
|
pct,
|
||||||
|
danger: clamped > 0 && clamped <= DANGER_THRESHOLD_SEC,
|
||||||
|
expired: clamped <= 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -128,6 +128,44 @@ export async function listReminders(operatorId: string) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ActivityRunRow {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
firedAt: Date;
|
||||||
|
reminderId: string | null;
|
||||||
|
reminderName: string;
|
||||||
|
isDeleted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listActivityRuns(operatorId: string): Promise<ActivityRunRow[]> {
|
||||||
|
// Mirrors the dashboard query but returns the full window (last 200) and
|
||||||
|
// exposes a stable shape. LEFT JOIN keeps orphan runs (whose reminder
|
||||||
|
// has been deleted but history was preserved) in the list.
|
||||||
|
const rows = await db.execute(sql`
|
||||||
|
SELECT
|
||||||
|
rr.id,
|
||||||
|
rr.status,
|
||||||
|
rr.fired_at,
|
||||||
|
rr.reminder_id,
|
||||||
|
COALESCE(r.name, rr.reminder_name, '(deleted reminder)') AS name,
|
||||||
|
r.id IS NULL AS is_deleted
|
||||||
|
FROM reminder_runs rr
|
||||||
|
LEFT JOIN reminders r ON r.id = rr.reminder_id
|
||||||
|
LEFT JOIN whatsapp_accounts wa ON wa.id = r.account_id
|
||||||
|
WHERE wa.operator_id = ${operatorId} OR r.id IS NULL
|
||||||
|
ORDER BY rr.fired_at DESC
|
||||||
|
LIMIT 200
|
||||||
|
`);
|
||||||
|
return (rows.rows as Array<Record<string, unknown>>).map((r) => ({
|
||||||
|
id: r.id as string,
|
||||||
|
status: r.status as string,
|
||||||
|
firedAt: r.fired_at as Date,
|
||||||
|
reminderId: (r.reminder_id as string | null) ?? null,
|
||||||
|
reminderName: r.name as string,
|
||||||
|
isDeleted: Boolean(r.is_deleted),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
export async function getReminderWithRuns(operatorId: string, reminderId: string) {
|
export async function getReminderWithRuns(operatorId: string, reminderId: string) {
|
||||||
const reminder = await db.query.reminders.findFirst({
|
const reminder = await db.query.reminders.findFirst({
|
||||||
where: (r, { eq }) => eq(r.id, reminderId),
|
where: (r, { eq }) => eq(r.id, reminderId),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user