yiekheng f19ea03e0d feat: edit reminders, mature recurrence, QR throttle, more tests
Reminders
- Reminder list / detail show recurrence summary ("Every Mon, Wed, Fri",
  "Every 2 weeks until 2027-01-01", etc).
- Detail page reorganised: each section (Account / Message / When /
  Groups) is itself a clickable card that deep-links into the wizard
  step in edit mode (editReminderId URL param). No standalone Edit
  button. Run history stays read-only.
- New /reminders/[id]/edit shell loads the row, encodes its state into
  wizard URL params, and forwards to /reminders/new. The wizard
  threads editReminderId through every step.
- updateReminderAction: validates ownership of both the existing
  reminder and the (possibly changed) target account, replaces targets
  + messages wholesale, re-arms the pg-boss job (singleton key picks
  up the new fire time).
- Wizard submit branches to updateReminderAction when editReminderId
  is set; button reads "Save changes" / "Saving…".
- Wizard default first-fire is now the current minute in the operator
  zone (not now+1h). Same-minute clicks bump silently to next minute
  via a 60 s grace window so the user isn't punished.
- /reminders empty state is filter-aware: "No failed reminders yet."
  when ?filter=failed and there are reminders in other states.

Recurrence
- Spec is now a structured object: { kind, interval, weeklyDays,
  monthDay, end }. Builder produces RRULEs with INTERVAL, BYDAY,
  BYMONTHDAY, COUNT, UNTIL as appropriate. specFromRrule round-trips
  for resuming/edit.
- When-step UI: frequency pills, "Every N days/weeks/…" interval,
  weekday picker (weekly), day-of-month input (monthly), end picker
  (Never / After N occurrences / On date), live human-readable
  summary preview.

QR pairing
- Throttle QR refresh to once per 25 s and detach the previous
  per-account session listener on Re-pair so listeners can't
  accumulate. The UI countdown was flicking every ~5 s because each
  Re-pair attached an extra listener — every Baileys QR event then
  triggered a fresh DB write + NOTIFY.

Tests (60 green total, +33 in this batch)
- recurrence.test.ts: extended to 25 tests covering interval,
  monthday, end conditions (COUNT/UNTIL), and round-trip parsing.
- date-picker.test.ts: 14 tests for splitDateTime / combineDateTime /
  validateScheduledAt (incl. the "click-too-fast" same-minute grace)
  and defaultFirstFireIso.
- /api/qr/[accountId] route.test.ts: 4 tests — 404 when no QR yet,
  404 on missing row, 200 with image/png + no-store + correct PNG
  bytes, and verifies the where-clause queries by accountId.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 01:22:22 +08:00

254 lines
7.9 KiB
TypeScript

import Link from "next/link";
import { redirect } from "next/navigation";
import {
ArrowLeftIcon,
PencilIcon,
CalendarIcon,
UsersIcon,
FileTextIcon,
SmartphoneIcon,
RepeatIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { getSeededOperator } from "@/lib/operator";
import { getAccount, listGroupsForAccount } from "@/lib/queries";
import { ReviewSubmitClient } from "./review-submit-client";
import { DateTime } from "luxon";
import { describeRecurrence, specFromRrule } from "@/lib/recurrence";
interface StepReviewParams {
step?: string;
accountId?: string;
groupIds?: string;
text?: string;
mediaId?: string;
caption?: string;
scheduledAt?: string;
rrule?: string;
editReminderId?: string;
}
interface StepReviewProps {
params: StepReviewParams;
}
function formatScheduledAt(iso: string, timezone: string): string {
try {
const dt = DateTime.fromISO(iso, { zone: timezone });
if (!dt.isValid) return iso;
return dt.toLocaleString(DateTime.DATETIME_FULL);
} catch {
return iso;
}
}
function editLink(
step: number,
accountId: string,
groupIds?: string,
text?: string,
mediaId?: string,
caption?: string,
scheduledAt?: string,
rrule?: string,
editReminderId?: string,
): string {
const sp = new URLSearchParams({ step: String(step), accountId });
if (groupIds) sp.set("groupIds", groupIds);
if (text) sp.set("text", text);
if (mediaId) sp.set("mediaId", mediaId);
if (caption) sp.set("caption", caption);
if (scheduledAt) sp.set("scheduledAt", scheduledAt);
if (rrule) sp.set("rrule", rrule);
if (editReminderId) sp.set("editReminderId", editReminderId);
return `/reminders/new?${sp.toString()}`;
}
export async function StepReview({ params }: StepReviewProps) {
const { accountId, groupIds, text, mediaId, caption, scheduledAt, rrule, editReminderId } = params;
if (!accountId || !scheduledAt || (!text && !mediaId)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
redirect("/reminders/new" as any);
}
const op = await getSeededOperator();
const timezone = op.defaultTimezone ?? "UTC";
// Fetch account details
const account = await getAccount(op.id, accountId);
if (!account) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
redirect("/reminders/new" as any);
}
// Fetch group names
const groupIdsArray = groupIds ? groupIds.split(",").filter(Boolean) : [];
const groupsResult =
groupIdsArray.length > 0 ? await listGroupsForAccount(op.id, accountId) : null;
const selectedGroups = groupsResult
? groupsResult.groups.filter((g) => groupIdsArray.includes(g.id))
: [];
const formattedDate = formatScheduledAt(scheduledAt, timezone);
// Back goes to step 4 (Groups, the previous step in the new order)
const backHref = editLink(4, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule, editReminderId);
return (
<div className="space-y-5">
<div>
<Button asChild variant="ghost" size="sm" className="-ml-2">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={backHref as any}>
<ArrowLeftIcon />
Back
</Link>
</Button>
</div>
<p className="text-sm text-muted-foreground">
Review your reminder before scheduling.
</p>
<div className="space-y-3">
{/* Account */}
<ReviewRow
icon={<SmartphoneIcon className="size-4" />}
label="Account"
editHref={editLink(1, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule, editReminderId)}
>
<span className="text-sm font-medium">{account.label}</span>
{account.phoneNumber && (
<span className="text-xs text-muted-foreground ml-1.5">{account.phoneNumber}</span>
)}
</ReviewRow>
{/* Message */}
<ReviewRow
icon={<FileTextIcon className="size-4" />}
label="Message"
editHref={editLink(2, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule, editReminderId)}
>
{mediaId ? (
<span className="text-sm text-muted-foreground">
Media file
{caption && (
<> with caption: <span className="text-foreground">{caption}</span></>
)}
{text && (
<> · <span className="text-foreground">{text}</span></>
)}
</span>
) : text ? (
<p className="text-sm whitespace-pre-wrap break-words">{text}</p>
) : (
<span className="text-sm text-muted-foreground italic">No message</span>
)}
</ReviewRow>
{/* When */}
<ReviewRow
icon={<CalendarIcon className="size-4" />}
label={rrule ? "First fire" : "When"}
editHref={editLink(3, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule, editReminderId)}
>
<span className="text-sm font-medium">{formattedDate}</span>
</ReviewRow>
{/* Recurrence (only if set) */}
{rrule && (
<ReviewRow
icon={<RepeatIcon className="size-4" />}
label="Repeats"
editHref={editLink(3, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule, editReminderId)}
>
<span className="text-sm font-medium">
{describeRecurrence(
specFromRrule(rrule),
DateTime.fromISO(scheduledAt!, { zone: timezone }),
)}
</span>
</ReviewRow>
)}
{/* Groups */}
<ReviewRow
icon={<UsersIcon className="size-4" />}
label="Groups"
editHref={editLink(4, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule, editReminderId)}
>
{selectedGroups.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{selectedGroups.map((g) => (
<span
key={g.id}
className="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground"
>
{g.name}
</span>
))}
</div>
) : (
<span className="text-sm text-muted-foreground italic">
No groups reminder will be saved without targets
</span>
)}
</ReviewRow>
</div>
<ReviewSubmitClient
accountId={accountId}
groupIds={groupIds}
text={text}
mediaId={mediaId}
caption={caption}
scheduledAt={scheduledAt}
rrule={rrule}
editReminderId={editReminderId}
timezone={timezone}
/>
</div>
);
}
function ReviewRow({
icon,
label,
editHref,
children,
}: {
icon: React.ReactNode;
label: string;
editHref: string;
children: React.ReactNode;
}) {
return (
<Card>
<CardContent className="py-3 px-4">
<div className="flex items-start gap-3">
<div className="flex size-7 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground mt-0.5">
{icon}
</div>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">
{label}
</p>
<div>{children}</div>
</div>
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={editHref as any}
className="shrink-0 flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors rounded px-1.5 py-1 hover:bg-muted"
aria-label={`Edit ${label}`}
>
<PencilIcon className="size-3" />
Edit
</Link>
</div>
</CardContent>
</Card>
);
}