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>
This commit is contained in:
parent
4f6d9c3f38
commit
f19ea03e0d
@ -14,8 +14,13 @@ import { pgNotifyWeb } from "./notify.js";
|
||||
const PAIR_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
const offByAccount = new Map<string, () => void>();
|
||||
const lastQrPayload = new Map<string, string>();
|
||||
const lastQrEmitMs = new Map<string, number>();
|
||||
const pairTimeouts = new Map<string, NodeJS.Timeout>();
|
||||
|
||||
// Minimum spacing between QR refresh notifications. Prevents the UI from
|
||||
// flashing through a new QR every few seconds when Baileys re-emits.
|
||||
const QR_THROTTLE_MS = 25_000;
|
||||
|
||||
async function abandonPair(accountId: string): Promise<{ existed: boolean; label: string | null }> {
|
||||
const account = await db.query.whatsappAccounts.findFirst({
|
||||
where: (a, { eq }) => eq(a.id, accountId),
|
||||
@ -51,6 +56,16 @@ export async function handleStartPairing(accountId: string): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Detach any listener still subscribed from a prior pairing attempt for
|
||||
// this account. Without this, repeated Re-pair clicks accumulate
|
||||
// listeners and each one writes a fresh QR to the DB on every Baileys
|
||||
// event — the UI then flashes through new QRs constantly.
|
||||
const prevOff = offByAccount.get(accountId);
|
||||
if (prevOff) {
|
||||
prevOff();
|
||||
offByAccount.delete(accountId);
|
||||
}
|
||||
|
||||
// For Re-pair, an old session may still be alive. Stop it so
|
||||
// sessionManager.start() actually opens a fresh socket and Baileys emits
|
||||
// a new QR. (start() is a no-op when a session is already registered.)
|
||||
@ -59,6 +74,7 @@ export async function handleStartPairing(accountId: string): Promise<void> {
|
||||
}
|
||||
// Clear any stale QR lingering from a prior attempt.
|
||||
lastQrPayload.delete(accountId);
|
||||
lastQrEmitMs.delete(accountId);
|
||||
await db
|
||||
.update(whatsappAccounts)
|
||||
.set({ lastQrPng: null })
|
||||
@ -69,7 +85,15 @@ export async function handleStartPairing(accountId: string): Promise<void> {
|
||||
try {
|
||||
if (event.type === "qr") {
|
||||
if (lastQrPayload.get(id) === event.payload) return;
|
||||
const lastEmit = lastQrEmitMs.get(id) ?? 0;
|
||||
const now = Date.now();
|
||||
if (now - lastEmit < QR_THROTTLE_MS) {
|
||||
// Baileys re-emits new QRs aggressively; surface no more than
|
||||
// one every QR_THROTTLE_MS so the UI countdown doesn't flicker.
|
||||
return;
|
||||
}
|
||||
lastQrPayload.set(id, event.payload);
|
||||
lastQrEmitMs.set(id, now);
|
||||
const png = await renderQrPng(event.payload);
|
||||
// PNG is too large (~5-10KB) for pg_notify (8000 byte limit).
|
||||
// Persist on the account row; web fetches via /api/qr/[id].
|
||||
@ -80,7 +104,7 @@ export async function handleStartPairing(accountId: string): Promise<void> {
|
||||
await pgNotifyWeb({
|
||||
type: "session.qr",
|
||||
accountId: id,
|
||||
ts: Date.now(),
|
||||
ts: now,
|
||||
});
|
||||
} else if (event.type === "open") {
|
||||
const t = pairTimeouts.get(id);
|
||||
@ -89,6 +113,7 @@ export async function handleStartPairing(accountId: string): Promise<void> {
|
||||
pairTimeouts.delete(id);
|
||||
}
|
||||
lastQrPayload.delete(id);
|
||||
lastQrEmitMs.delete(id);
|
||||
offByAccount.delete(id);
|
||||
const session = sessionManager.getSession(id);
|
||||
let synced = 0;
|
||||
@ -122,6 +147,7 @@ export async function handleStartPairing(accountId: string): Promise<void> {
|
||||
pairTimeouts.delete(id);
|
||||
}
|
||||
lastQrPayload.delete(id);
|
||||
lastQrEmitMs.delete(id);
|
||||
offByAccount.delete(id);
|
||||
await pgNotifyWeb({ type: "session.timeout", accountId: id });
|
||||
off();
|
||||
|
||||
@ -149,3 +149,119 @@ export async function createReminderAction(
|
||||
|
||||
return { ok: true, reminderId };
|
||||
}
|
||||
|
||||
const updateReminderSchema = createReminderSchema.and(
|
||||
z.object({ reminderId: z.string().uuid() }),
|
||||
);
|
||||
|
||||
export type UpdateReminderResult =
|
||||
| { ok: true; reminderId: string }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export async function updateReminderAction(
|
||||
input: z.infer<typeof updateReminderSchema>,
|
||||
): Promise<UpdateReminderResult> {
|
||||
await rateLimit("update-reminder");
|
||||
const parsed = updateReminderSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input" };
|
||||
}
|
||||
const {
|
||||
reminderId,
|
||||
accountId,
|
||||
groupIds,
|
||||
text,
|
||||
mediaId,
|
||||
caption,
|
||||
scheduledAtIso,
|
||||
rrule,
|
||||
timezone,
|
||||
} = parsed.data;
|
||||
|
||||
const op = await getSeededOperator();
|
||||
|
||||
// Verify the reminder exists, the operator owns its account, and the
|
||||
// (possibly changed) target account is also theirs.
|
||||
const existing = await db.query.reminders.findFirst({
|
||||
where: (r, { eq }) => eq(r.id, reminderId),
|
||||
});
|
||||
if (!existing) return { ok: false, error: "Reminder not found" };
|
||||
const ownerOfExisting = await db.query.whatsappAccounts.findFirst({
|
||||
where: (a, { eq, and }) => and(eq(a.id, existing.accountId), eq(a.operatorId, op.id)),
|
||||
});
|
||||
if (!ownerOfExisting) return { ok: false, error: "Reminder not yours" };
|
||||
const targetAccount = await db.query.whatsappAccounts.findFirst({
|
||||
where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, op.id)),
|
||||
});
|
||||
if (!targetAccount) return { ok: false, error: "Account not yours" };
|
||||
|
||||
const scheduledAt = DateTime.fromISO(scheduledAtIso, { zone: timezone }).toJSDate();
|
||||
if (Number.isNaN(scheduledAt.getTime())) {
|
||||
return { ok: false, error: "Invalid date" };
|
||||
}
|
||||
if (scheduledAt.getTime() <= Date.now()) {
|
||||
return { ok: false, error: "Time is in the past" };
|
||||
}
|
||||
|
||||
const groups = await db.query.whatsappGroups.findMany({
|
||||
where: (g, { eq, inArray, and }) => and(eq(g.accountId, accountId), inArray(g.id, groupIds)),
|
||||
});
|
||||
if (groups.length !== groupIds.length) {
|
||||
return { ok: false, error: "One or more groups don't belong to this account" };
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(reminders)
|
||||
.set({
|
||||
accountId,
|
||||
name: (text ?? caption ?? "Reminder").slice(0, 50),
|
||||
scheduleKind: rrule ? "recurring" : "one_off",
|
||||
scheduledAt,
|
||||
rrule: rrule ?? null,
|
||||
timezone,
|
||||
status: "active",
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(reminders.id, reminderId));
|
||||
|
||||
// Replace targets and messages wholesale — simpler than diffing.
|
||||
await tx.delete(reminderTargets).where(eq(reminderTargets.reminderId, reminderId));
|
||||
if (groupIds.length > 0) {
|
||||
await tx.insert(reminderTargets).values(
|
||||
groupIds.map((groupId, position) => ({ reminderId, groupId, position })),
|
||||
);
|
||||
}
|
||||
|
||||
await tx.delete(reminderMessages).where(eq(reminderMessages.reminderId, reminderId));
|
||||
if (text && !mediaId) {
|
||||
await tx.insert(reminderMessages).values({
|
||||
reminderId,
|
||||
position: 0,
|
||||
kind: "text",
|
||||
textContent: text,
|
||||
mediaId: null,
|
||||
});
|
||||
} else if (mediaId) {
|
||||
await tx.insert(reminderMessages).values({
|
||||
reminderId,
|
||||
position: 0,
|
||||
kind: "media",
|
||||
textContent: caption ?? text ?? null,
|
||||
mediaId,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Re-arm the pg-boss job at the new scheduled time. The handler uses
|
||||
// singletonKey=reminder:<id> so this supersedes the prior arming.
|
||||
await pgNotifyBot({
|
||||
type: "reminder.schedule",
|
||||
reminderId,
|
||||
scheduledAtIso: scheduledAt.toISOString(),
|
||||
});
|
||||
|
||||
revalidatePath("/reminders");
|
||||
revalidatePath(`/reminders/${reminderId}`);
|
||||
return { ok: true, reminderId };
|
||||
}
|
||||
|
||||
82
apps/web/src/app/api/qr/[accountId]/route.test.ts
Normal file
82
apps/web/src/app/api/qr/[accountId]/route.test.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Mock the db module before importing the route — the route reaches into
|
||||
// `db.query.whatsappAccounts.findFirst`. Each test sets the resolved value.
|
||||
const findFirstMock = vi.fn();
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: {
|
||||
query: {
|
||||
whatsappAccounts: {
|
||||
findFirst: (...args: unknown[]) => findFirstMock(...args),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { GET } from "./route";
|
||||
|
||||
const ACCOUNT_ID = "11111111-1111-1111-1111-111111111111";
|
||||
const ctx = { params: Promise.resolve({ accountId: ACCOUNT_ID }) };
|
||||
|
||||
// "PNG\r\n\x1A\n" — start of a valid PNG, in base64.
|
||||
const FAKE_PNG_BASE64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
|
||||
|
||||
describe("GET /api/qr/[accountId]", () => {
|
||||
beforeEach(() => {
|
||||
findFirstMock.mockReset();
|
||||
});
|
||||
|
||||
it("returns 404 when the account has no QR yet", async () => {
|
||||
findFirstMock.mockResolvedValue({ lastQrPng: null });
|
||||
const res = await GET(new Request("http://x/api/qr/x"), ctx);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 404 when the account row doesn't exist", async () => {
|
||||
findFirstMock.mockResolvedValue(undefined);
|
||||
const res = await GET(new Request("http://x/api/qr/x"), ctx);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 200 with the PNG bytes and the right headers when a QR is present", async () => {
|
||||
findFirstMock.mockResolvedValue({ lastQrPng: FAKE_PNG_BASE64 });
|
||||
const res = await GET(new Request("http://x/api/qr/x"), ctx);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type")).toBe("image/png");
|
||||
// The endpoint serves a fresh QR each time the SSE bumps the timestamp,
|
||||
// so it must not be cached.
|
||||
expect(res.headers.get("cache-control")).toBe("no-store");
|
||||
|
||||
// Body should round-trip exactly back to the stored base64.
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
expect(buf.toString("base64")).toBe(FAKE_PNG_BASE64);
|
||||
// Sanity check: starts with the PNG magic bytes \x89 P N G.
|
||||
expect(buf[0]).toBe(0x89);
|
||||
expect(buf.subarray(1, 4).toString()).toBe("PNG");
|
||||
});
|
||||
|
||||
it("queries the DB by the URL accountId", async () => {
|
||||
findFirstMock.mockResolvedValue({ lastQrPng: FAKE_PNG_BASE64 });
|
||||
await GET(new Request("http://x/api/qr/x"), ctx);
|
||||
|
||||
expect(findFirstMock).toHaveBeenCalledTimes(1);
|
||||
const arg = findFirstMock.mock.calls[0]![0] as { where: unknown; columns: unknown };
|
||||
expect(arg.columns).toEqual({ lastQrPng: true });
|
||||
// Exercise the `where` predicate Drizzle would call with the schema +
|
||||
// operator helpers. The route passes a closure that only uses `eq`.
|
||||
let captured: unknown = null;
|
||||
const fakeAccount = { id: "fake_id_col" };
|
||||
const helpers = {
|
||||
eq: (a: unknown, b: unknown) => {
|
||||
captured = [a, b];
|
||||
return "EQ_PREDICATE";
|
||||
},
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = (arg.where as any)(fakeAccount, helpers);
|
||||
expect(result).toBe("EQ_PREDICATE");
|
||||
expect(captured).toEqual([fakeAccount.id, ACCOUNT_ID]);
|
||||
});
|
||||
});
|
||||
50
apps/web/src/app/reminders/[id]/edit/page.tsx
Normal file
50
apps/web/src/app/reminders/[id]/edit/page.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
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);
|
||||
}
|
||||
@ -7,15 +7,16 @@ import {
|
||||
UsersIcon,
|
||||
ClockIcon,
|
||||
FileTextIcon,
|
||||
RepeatIcon,
|
||||
PencilIcon,
|
||||
} from "lucide-react";
|
||||
import { DateTime } from "luxon";
|
||||
import { describeRecurrence, specFromRrule } from "@/lib/recurrence";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
@ -30,9 +31,6 @@ import { getSeededOperator } from "@/lib/operator";
|
||||
import { getReminderWithRuns } from "@/lib/queries";
|
||||
import { DeleteDialog } from "./delete-dialog";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
function formatWhen(date: Date | null, tz: string): string {
|
||||
if (!date) return "—";
|
||||
return new Intl.DateTimeFormat("en-MY", {
|
||||
@ -45,9 +43,6 @@ function formatWhen(date: Date | null, tz: string): string {
|
||||
}).format(new Date(date));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status pill
|
||||
// ---------------------------------------------------------------------------
|
||||
const STATUS_STYLES: Record<string, string> = {
|
||||
active:
|
||||
"bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent",
|
||||
@ -73,10 +68,6 @@ function StatusPill({ status }: { status: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
@ -94,9 +85,40 @@ export default async function ReminderDetailPage({ params }: Props) {
|
||||
const { reminder, account, targets, messages, runs } = data;
|
||||
const tz = op.defaultTimezone ?? "UTC";
|
||||
|
||||
// Build a wizard URL pointing at `step` with the current reminder state
|
||||
// serialised — the wizard's review-submit detects editReminderId and
|
||||
// routes to updateReminderAction instead of createReminderAction.
|
||||
function editStepHref(step: number): string {
|
||||
const sp = new URLSearchParams({
|
||||
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 =
|
||||
"transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer";
|
||||
const linkWrapperClasses =
|
||||
"block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2";
|
||||
|
||||
return (
|
||||
<div className="px-4 py-6 sm:px-6 sm:py-8 max-w-3xl mx-auto space-y-6">
|
||||
{/* Back link */}
|
||||
<Button asChild variant="ghost" size="sm" className="-ml-2">
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
<Link href={"/reminders" as any}>
|
||||
@ -105,7 +127,6 @@ export default async function ReminderDetailPage({ params }: Props) {
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start gap-3 flex-wrap">
|
||||
<h1 className="text-2xl font-semibold tracking-tight leading-tight flex-1 min-w-0">
|
||||
@ -113,76 +134,136 @@ export default async function ReminderDetailPage({ params }: Props) {
|
||||
</h1>
|
||||
<StatusPill status={reminder.status} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<CalendarIcon className="size-3.5 shrink-0" />
|
||||
<span>When: {formatWhen(reminder.scheduledAt, tz)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<SmartphoneIcon className="size-3.5 shrink-0" />
|
||||
<span>Account: {account.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
Tap any section below to edit it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Message body */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-base font-medium tracking-tight flex items-center gap-2">
|
||||
{/* Account — click to edit step 1 */}
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
<Link href={editStepHref(1) as any} className={linkWrapperClasses} aria-label="Edit account">
|
||||
<Card className={cardClasses}>
|
||||
<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">
|
||||
<SmartphoneIcon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Account
|
||||
</p>
|
||||
<p className="text-sm font-medium truncate">{account.label}</p>
|
||||
{account.phoneNumber && (
|
||||
<p className="text-xs text-muted-foreground truncate">{account.phoneNumber}</p>
|
||||
)}
|
||||
</div>
|
||||
<PencilIcon className="size-3.5 text-muted-foreground/60 mt-1 shrink-0" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
{/* Message — click to edit step 2 */}
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
<Link href={editStepHref(2) as any} className={linkWrapperClasses} aria-label="Edit message">
|
||||
<Card className={cardClasses}>
|
||||
<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">
|
||||
<FileTextIcon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Message
|
||||
</h2>
|
||||
<Card>
|
||||
<CardContent className="py-4 space-y-3">
|
||||
</p>
|
||||
{messages.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">No message parts defined.</p>
|
||||
) : (
|
||||
messages.map((msg, i) => (
|
||||
<div key={msg.id}>
|
||||
{i > 0 && <Separator className="my-3" />}
|
||||
{i > 0 && <Separator className="my-2" />}
|
||||
{msg.kind === "text" && msg.textContent ? (
|
||||
<p className="text-sm whitespace-pre-wrap">{msg.textContent}</p>
|
||||
<p className="text-sm whitespace-pre-wrap break-words">
|
||||
{msg.textContent}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-mono text-muted-foreground">
|
||||
[{msg.kind}]{msg.textContent ? ` ${msg.textContent}` : ""}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/70 italic">
|
||||
Media preview coming soon.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<PencilIcon className="size-3.5 text-muted-foreground/60 mt-1 shrink-0" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</Link>
|
||||
|
||||
{/* Target groups */}
|
||||
{targets.length > 0 && (
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-base font-medium tracking-tight flex items-center gap-2">
|
||||
{/* When / Recurrence — click to edit step 3 */}
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
<Link href={editStepHref(3) as any} className={linkWrapperClasses} aria-label="Edit schedule">
|
||||
<Card className={cardClasses}>
|
||||
<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">
|
||||
<CalendarIcon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{reminder.rrule ? "First fire" : "When"}
|
||||
</p>
|
||||
<p className="text-sm font-medium">{formatWhen(reminder.scheduledAt, tz)}</p>
|
||||
{reminder.rrule && reminder.scheduledAt ? (
|
||||
<p className="flex items-center gap-1.5 text-xs text-primary/80">
|
||||
<RepeatIcon className="size-3 shrink-0" />
|
||||
{describeRecurrence(
|
||||
specFromRrule(reminder.rrule),
|
||||
DateTime.fromJSDate(reminder.scheduledAt).setZone(reminder.timezone),
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">One-off</p>
|
||||
)}
|
||||
</div>
|
||||
<PencilIcon className="size-3.5 text-muted-foreground/60 mt-1 shrink-0" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
{/* Groups — click to edit step 4 */}
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
<Link href={editStepHref(4) as any} className={linkWrapperClasses} aria-label="Edit groups">
|
||||
<Card className={cardClasses}>
|
||||
<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">
|
||||
<UsersIcon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Groups
|
||||
<Badge variant="outline" className="ml-1">
|
||||
{targets.length}
|
||||
</Badge>
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{targets.length > 0 ? ` · ${targets.length}` : " · none"}
|
||||
</p>
|
||||
{targets.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
No groups — reminder won't deliver until you add at least one
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{targets.map((t) => (
|
||||
<Badge key={t.groupId} variant="secondary">
|
||||
{t.groupName}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
<PencilIcon className="size-3.5 text-muted-foreground/60 mt-1 shrink-0" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Run history */}
|
||||
{/* Run history — read-only */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-base font-medium tracking-tight flex items-center gap-2">
|
||||
<ClockIcon className="size-4 text-muted-foreground" />
|
||||
@ -233,8 +314,8 @@ export default async function ReminderDetailPage({ params }: Props) {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Action footer */}
|
||||
<div className="flex items-center justify-end pt-2 border-t">
|
||||
{/* Action footer — Delete only; section cards above handle editing */}
|
||||
<div className="flex items-center justify-end gap-2 pt-2 border-t">
|
||||
<DeleteDialog reminderId={reminder.id} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -15,7 +15,9 @@ interface PageProps {
|
||||
mediaId?: string;
|
||||
caption?: string;
|
||||
scheduledAt?: string;
|
||||
rrule?: string;
|
||||
groupId?: string;
|
||||
editReminderId?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
@ -23,10 +25,13 @@ export default async function NewReminderPage({ searchParams }: PageProps) {
|
||||
const sp = await searchParams;
|
||||
const step = Number(sp.step ?? "1");
|
||||
if (![1, 2, 3, 4, 5].includes(step)) notFound();
|
||||
const isEdit = Boolean(sp.editReminderId);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-2xl space-y-6 p-4 sm:p-6">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">New Reminder</h1>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{isEdit ? "Edit Reminder" : "New Reminder"}
|
||||
</h1>
|
||||
<Stepper current={step} />
|
||||
{step === 1 && <StepAccount />}
|
||||
{step === 2 && <StepCompose params={sp} />}
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
import Link from "next/link";
|
||||
import { PlusIcon, BellIcon, CalendarIcon, UsersIcon } from "lucide-react";
|
||||
import { PlusIcon, BellIcon, CalendarIcon, UsersIcon, RepeatIcon } from "lucide-react";
|
||||
import { DateTime } from "luxon";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { getSeededOperator } from "@/lib/operator";
|
||||
import { listReminders } from "@/lib/queries";
|
||||
import { describeRecurrence, specFromRrule } from "@/lib/recurrence";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@ -138,12 +140,23 @@ export default async function RemindersPage({ searchParams }: PageProps) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* When + group count */}
|
||||
{/* When + recurrence + group count */}
|
||||
<div className="shrink-0 text-right space-y-1">
|
||||
<div className="flex items-center justify-end gap-1 text-xs text-muted-foreground">
|
||||
<CalendarIcon className="size-3 shrink-0" />
|
||||
<span>{formatWhen(reminder.scheduledAt, tz)}</span>
|
||||
</div>
|
||||
{reminder.rrule && reminder.scheduledAt ? (
|
||||
<div className="flex items-center justify-end gap-1 text-xs text-primary/80">
|
||||
<RepeatIcon className="size-3 shrink-0" />
|
||||
<span>
|
||||
{describeRecurrence(
|
||||
specFromRrule(reminder.rrule),
|
||||
DateTime.fromJSDate(reminder.scheduledAt).setZone(reminder.timezone),
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{reminder.groupCount > 0 && (
|
||||
<div className="flex items-center justify-end gap-1 text-xs text-muted-foreground">
|
||||
<UsersIcon className="size-3 shrink-0" />
|
||||
@ -164,11 +177,18 @@ export default async function RemindersPage({ searchParams }: PageProps) {
|
||||
<CardContent className="flex flex-col items-center gap-4 py-12 text-center">
|
||||
<BellIcon className="size-10 text-muted-foreground/40" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">No reminders yet.</p>
|
||||
<p className="text-sm font-medium">
|
||||
{filter === "all"
|
||||
? "No reminders yet."
|
||||
: `No ${filter} reminders yet.`}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Create a reminder to start sending scheduled WhatsApp messages.
|
||||
{allReminders.length === 0
|
||||
? "Create a reminder to start sending scheduled WhatsApp messages."
|
||||
: `Reminders in other states aren't shown by this filter.`}
|
||||
</p>
|
||||
</div>
|
||||
{allReminders.length === 0 && (
|
||||
<Button asChild size="sm">
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
<Link href={"/reminders/new" as any}>
|
||||
@ -176,6 +196,7 @@ export default async function RemindersPage({ searchParams }: PageProps) {
|
||||
New Reminder
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@ -19,6 +19,8 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
interface PassThroughParams {
|
||||
scheduledAt?: string;
|
||||
rrule?: string;
|
||||
editReminderId?: string;
|
||||
}
|
||||
|
||||
interface ComposeFormClientProps {
|
||||
@ -118,6 +120,8 @@ export function ComposeFormClient({
|
||||
if (mediaId) sp.set("mediaId", mediaId);
|
||||
if (caption.trim()) sp.set("caption", caption.trim());
|
||||
if (passThroughParams.scheduledAt) sp.set("scheduledAt", passThroughParams.scheduledAt);
|
||||
if (passThroughParams.rrule) sp.set("rrule", passThroughParams.rrule);
|
||||
if (passThroughParams.editReminderId) sp.set("editReminderId", passThroughParams.editReminderId);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
router.push(`/reminders/new?${sp.toString()}` as any);
|
||||
}
|
||||
|
||||
@ -20,6 +20,7 @@ interface PassThroughParams {
|
||||
caption?: string;
|
||||
scheduledAt?: string;
|
||||
rrule?: string;
|
||||
editReminderId?: string;
|
||||
}
|
||||
|
||||
interface GroupsFormClientProps {
|
||||
@ -72,6 +73,7 @@ export function GroupsFormClient({
|
||||
if (passThroughParams.caption) sp.set("caption", passThroughParams.caption);
|
||||
if (passThroughParams.scheduledAt) sp.set("scheduledAt", passThroughParams.scheduledAt);
|
||||
if (passThroughParams.rrule) sp.set("rrule", passThroughParams.rrule);
|
||||
if (passThroughParams.editReminderId) sp.set("editReminderId", passThroughParams.editReminderId);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
router.push(`/reminders/new?${sp.toString()}` as any);
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@ import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { CalendarCheckIcon, AlertCircleIcon, Loader2Icon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { createReminderAction } from "@/actions/reminders";
|
||||
import { createReminderAction, updateReminderAction } from "@/actions/reminders";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ReviewSubmitClientProps {
|
||||
@ -15,6 +15,7 @@ interface ReviewSubmitClientProps {
|
||||
caption?: string;
|
||||
scheduledAt: string;
|
||||
rrule?: string;
|
||||
editReminderId?: string;
|
||||
timezone: string;
|
||||
}
|
||||
|
||||
@ -26,6 +27,7 @@ export function ReviewSubmitClient({
|
||||
caption,
|
||||
scheduledAt,
|
||||
rrule,
|
||||
editReminderId,
|
||||
timezone,
|
||||
}: ReviewSubmitClientProps) {
|
||||
const router = useRouter();
|
||||
@ -37,7 +39,7 @@ export function ReviewSubmitClient({
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await createReminderAction({
|
||||
const payload = {
|
||||
accountId,
|
||||
groupIds: groupIds ? groupIds.split(",").filter(Boolean) : [],
|
||||
text: text ?? null,
|
||||
@ -46,7 +48,10 @@ export function ReviewSubmitClient({
|
||||
scheduledAtIso: scheduledAt,
|
||||
rrule: rrule ?? null,
|
||||
timezone,
|
||||
});
|
||||
};
|
||||
const result = editReminderId
|
||||
? await updateReminderAction({ ...payload, reminderId: editReminderId })
|
||||
: await createReminderAction(payload);
|
||||
|
||||
if (result.ok) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@ -81,12 +86,12 @@ export function ReviewSubmitClient({
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
Scheduling…
|
||||
{editReminderId ? "Saving…" : "Scheduling…"}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CalendarCheckIcon className="size-4" />
|
||||
Schedule Reminder
|
||||
{editReminderId ? "Save changes" : "Schedule Reminder"}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@ -12,6 +12,8 @@ interface StepComposeParams {
|
||||
mediaId?: string;
|
||||
caption?: string;
|
||||
scheduledAt?: string;
|
||||
rrule?: string;
|
||||
editReminderId?: string;
|
||||
}
|
||||
|
||||
interface StepComposeProps {
|
||||
@ -52,6 +54,8 @@ export function StepCompose({ params }: StepComposeProps) {
|
||||
initialCaption={caption}
|
||||
passThroughParams={{
|
||||
scheduledAt: params.scheduledAt,
|
||||
rrule: params.rrule,
|
||||
editReminderId: params.editReminderId,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -16,6 +16,7 @@ interface StepGroupsParams {
|
||||
scheduledAt?: string;
|
||||
rrule?: string;
|
||||
groupId?: string;
|
||||
editReminderId?: string;
|
||||
}
|
||||
|
||||
interface StepGroupsProps {
|
||||
@ -31,6 +32,7 @@ export async function StepGroups({ params }: StepGroupsProps) {
|
||||
text,
|
||||
mediaId,
|
||||
rrule,
|
||||
editReminderId,
|
||||
} = params;
|
||||
|
||||
if (!accountId || !scheduledAt || (!text && !mediaId)) {
|
||||
@ -74,6 +76,7 @@ export async function StepGroups({ params }: StepGroupsProps) {
|
||||
if (params.caption) backParams.set("caption", params.caption);
|
||||
if (scheduledAt) backParams.set("scheduledAt", scheduledAt);
|
||||
if (rrule) backParams.set("rrule", rrule);
|
||||
if (editReminderId) backParams.set("editReminderId", editReminderId);
|
||||
const backHref = `/reminders/new?${backParams.toString()}`;
|
||||
|
||||
return (
|
||||
@ -103,6 +106,7 @@ export async function StepGroups({ params }: StepGroupsProps) {
|
||||
caption: params.caption,
|
||||
scheduledAt: params.scheduledAt,
|
||||
rrule,
|
||||
editReminderId,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@ -118,6 +122,7 @@ interface PassThroughParams {
|
||||
caption?: string;
|
||||
scheduledAt?: string;
|
||||
rrule?: string;
|
||||
editReminderId?: string;
|
||||
}
|
||||
|
||||
function StepGroupsForm({
|
||||
|
||||
@ -15,7 +15,7 @@ import { getSeededOperator } from "@/lib/operator";
|
||||
import { getAccount, listGroupsForAccount } from "@/lib/queries";
|
||||
import { ReviewSubmitClient } from "./review-submit-client";
|
||||
import { DateTime } from "luxon";
|
||||
import { describeRecurrence, kindFromRrule } from "@/lib/recurrence";
|
||||
import { describeRecurrence, specFromRrule } from "@/lib/recurrence";
|
||||
|
||||
interface StepReviewParams {
|
||||
step?: string;
|
||||
@ -26,25 +26,13 @@ interface StepReviewParams {
|
||||
caption?: string;
|
||||
scheduledAt?: string;
|
||||
rrule?: string;
|
||||
editReminderId?: string;
|
||||
}
|
||||
|
||||
interface StepReviewProps {
|
||||
params: StepReviewParams;
|
||||
}
|
||||
|
||||
const WEEKDAY_TO_ISO: Record<string, number> = {
|
||||
MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6, SU: 7,
|
||||
};
|
||||
function parseWeeklyDaysFromRrule(rrule: string | undefined): number[] {
|
||||
if (!rrule) return [];
|
||||
const m = rrule.match(/BYDAY=([A-Z,]+)/i);
|
||||
if (!m) return [];
|
||||
return m[1]!
|
||||
.split(",")
|
||||
.map((d) => WEEKDAY_TO_ISO[d.toUpperCase()])
|
||||
.filter((d): d is number => d !== undefined);
|
||||
}
|
||||
|
||||
function formatScheduledAt(iso: string, timezone: string): string {
|
||||
try {
|
||||
const dt = DateTime.fromISO(iso, { zone: timezone });
|
||||
@ -64,6 +52,7 @@ function editLink(
|
||||
caption?: string,
|
||||
scheduledAt?: string,
|
||||
rrule?: string,
|
||||
editReminderId?: string,
|
||||
): string {
|
||||
const sp = new URLSearchParams({ step: String(step), accountId });
|
||||
if (groupIds) sp.set("groupIds", groupIds);
|
||||
@ -72,11 +61,12 @@ function editLink(
|
||||
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 } = params;
|
||||
const { accountId, groupIds, text, mediaId, caption, scheduledAt, rrule, editReminderId } = params;
|
||||
|
||||
if (!accountId || !scheduledAt || (!text && !mediaId)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@ -104,7 +94,7 @@ export async function StepReview({ params }: StepReviewProps) {
|
||||
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);
|
||||
const backHref = editLink(4, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule, editReminderId);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
@ -127,7 +117,7 @@ export async function StepReview({ params }: StepReviewProps) {
|
||||
<ReviewRow
|
||||
icon={<SmartphoneIcon className="size-4" />}
|
||||
label="Account"
|
||||
editHref={editLink(1, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule)}
|
||||
editHref={editLink(1, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule, editReminderId)}
|
||||
>
|
||||
<span className="text-sm font-medium">{account.label}</span>
|
||||
{account.phoneNumber && (
|
||||
@ -139,7 +129,7 @@ export async function StepReview({ params }: StepReviewProps) {
|
||||
<ReviewRow
|
||||
icon={<FileTextIcon className="size-4" />}
|
||||
label="Message"
|
||||
editHref={editLink(2, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule)}
|
||||
editHref={editLink(2, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule, editReminderId)}
|
||||
>
|
||||
{mediaId ? (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
@ -162,7 +152,7 @@ export async function StepReview({ params }: StepReviewProps) {
|
||||
<ReviewRow
|
||||
icon={<CalendarIcon className="size-4" />}
|
||||
label={rrule ? "First fire" : "When"}
|
||||
editHref={editLink(3, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule)}
|
||||
editHref={editLink(3, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule, editReminderId)}
|
||||
>
|
||||
<span className="text-sm font-medium">{formattedDate}</span>
|
||||
</ReviewRow>
|
||||
@ -172,13 +162,12 @@ export async function StepReview({ params }: StepReviewProps) {
|
||||
<ReviewRow
|
||||
icon={<RepeatIcon className="size-4" />}
|
||||
label="Repeats"
|
||||
editHref={editLink(3, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule)}
|
||||
editHref={editLink(3, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule, editReminderId)}
|
||||
>
|
||||
<span className="text-sm font-medium">
|
||||
{describeRecurrence(
|
||||
kindFromRrule(rrule),
|
||||
specFromRrule(rrule),
|
||||
DateTime.fromISO(scheduledAt!, { zone: timezone }),
|
||||
parseWeeklyDaysFromRrule(rrule),
|
||||
)}
|
||||
</span>
|
||||
</ReviewRow>
|
||||
@ -188,7 +177,7 @@ export async function StepReview({ params }: StepReviewProps) {
|
||||
<ReviewRow
|
||||
icon={<UsersIcon className="size-4" />}
|
||||
label="Groups"
|
||||
editHref={editLink(4, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule)}
|
||||
editHref={editLink(4, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule, editReminderId)}
|
||||
>
|
||||
{selectedGroups.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
@ -217,6 +206,7 @@ export async function StepReview({ params }: StepReviewProps) {
|
||||
caption={caption}
|
||||
scheduledAt={scheduledAt}
|
||||
rrule={rrule}
|
||||
editReminderId={editReminderId}
|
||||
timezone={timezone}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ArrowLeftIcon } from "lucide-react";
|
||||
import { DateTime } from "luxon";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getSeededOperator } from "@/lib/operator";
|
||||
import { WhenFormClient } from "./when-form-client";
|
||||
import { kindFromRrule } from "@/lib/recurrence";
|
||||
import { specFromRrule } from "@/lib/recurrence";
|
||||
import { defaultFirstFireIso } from "@/lib/date-picker";
|
||||
|
||||
interface StepWhenParams {
|
||||
step?: string;
|
||||
@ -16,28 +16,15 @@ interface StepWhenParams {
|
||||
caption?: string;
|
||||
scheduledAt?: string;
|
||||
rrule?: string;
|
||||
editReminderId?: string;
|
||||
}
|
||||
|
||||
interface StepWhenProps {
|
||||
params: StepWhenParams;
|
||||
}
|
||||
|
||||
const WEEKDAY_TO_ISO: Record<string, number> = {
|
||||
MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6, SU: 7,
|
||||
};
|
||||
|
||||
function parseWeeklyDays(rrule: string | undefined): number[] {
|
||||
if (!rrule) return [];
|
||||
const m = rrule.match(/BYDAY=([A-Z,]+)/i);
|
||||
if (!m) return [];
|
||||
return m[1]!
|
||||
.split(",")
|
||||
.map((d) => WEEKDAY_TO_ISO[d.toUpperCase()])
|
||||
.filter((d): d is number => d !== undefined);
|
||||
}
|
||||
|
||||
export async function StepWhen({ params }: StepWhenProps) {
|
||||
const { accountId, groupIds, text, mediaId, caption, scheduledAt, rrule } = params;
|
||||
const { accountId, groupIds, text, mediaId, caption, scheduledAt, rrule, editReminderId } = params;
|
||||
|
||||
if (!accountId || (!text && !mediaId)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@ -53,6 +40,7 @@ export async function StepWhen({ params }: StepWhenProps) {
|
||||
if (mediaId) backParams.set("mediaId", mediaId);
|
||||
if (caption) backParams.set("caption", caption);
|
||||
if (rrule) backParams.set("rrule", rrule);
|
||||
if (editReminderId) backParams.set("editReminderId", editReminderId);
|
||||
const backHref = `/reminders/new?${backParams.toString()}`;
|
||||
|
||||
return (
|
||||
@ -76,13 +64,9 @@ export async function StepWhen({ params }: StepWhenProps) {
|
||||
accountId={accountId}
|
||||
groupIds={groupIds ?? ""}
|
||||
timezone={timezone}
|
||||
initialDefaultIso={
|
||||
scheduledAt ??
|
||||
DateTime.now().setZone(timezone).plus({ hours: 1 }).startOf("minute").toISO()!
|
||||
}
|
||||
initialKind={kindFromRrule(rrule)}
|
||||
initialWeeklyDays={parseWeeklyDays(rrule)}
|
||||
passThroughParams={{ text, mediaId, caption }}
|
||||
initialDefaultIso={scheduledAt ?? defaultFirstFireIso(timezone)}
|
||||
initialSpec={specFromRrule(rrule)}
|
||||
passThroughParams={{ text, mediaId, caption, editReminderId }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -11,13 +11,18 @@ import { cn } from "@/lib/utils";
|
||||
import {
|
||||
WEEKDAY_LABELS,
|
||||
buildRrule,
|
||||
describeRecurrence,
|
||||
type RecurrenceKind,
|
||||
type RecurrenceSpec,
|
||||
type EndKind,
|
||||
} from "@/lib/recurrence";
|
||||
import { splitDateTime, validateScheduledAt } from "@/lib/date-picker";
|
||||
|
||||
interface PassThroughParams {
|
||||
text?: string;
|
||||
mediaId?: string;
|
||||
caption?: string;
|
||||
editReminderId?: string;
|
||||
}
|
||||
|
||||
interface WhenFormClientProps {
|
||||
@ -25,8 +30,7 @@ interface WhenFormClientProps {
|
||||
groupIds: string;
|
||||
timezone: string;
|
||||
initialDefaultIso: string;
|
||||
initialKind?: RecurrenceKind;
|
||||
initialWeeklyDays?: number[];
|
||||
initialSpec?: RecurrenceSpec;
|
||||
passThroughParams: PassThroughParams;
|
||||
}
|
||||
|
||||
@ -38,19 +42,19 @@ const KINDS: Array<{ value: RecurrenceKind; label: string }> = [
|
||||
{ value: "yearly", label: "Yearly" },
|
||||
];
|
||||
|
||||
function splitDateTime(iso: string, tz: string): { date: string; time: string } {
|
||||
const dt = DateTime.fromISO(iso, { zone: tz });
|
||||
if (!dt.isValid) return { date: "", time: "" };
|
||||
return { date: dt.toFormat("yyyy-MM-dd"), time: dt.toFormat("HH:mm") };
|
||||
}
|
||||
const FREQ_UNIT: Record<Exclude<RecurrenceKind, "none">, string> = {
|
||||
daily: "day",
|
||||
weekly: "week",
|
||||
monthly: "month",
|
||||
yearly: "year",
|
||||
};
|
||||
|
||||
export function WhenFormClient({
|
||||
accountId,
|
||||
groupIds,
|
||||
timezone,
|
||||
initialDefaultIso,
|
||||
initialKind = "none",
|
||||
initialWeeklyDays = [],
|
||||
initialSpec,
|
||||
passThroughParams,
|
||||
}: WhenFormClientProps) {
|
||||
const router = useRouter();
|
||||
@ -58,8 +62,19 @@ export function WhenFormClient({
|
||||
|
||||
const [date, setDate] = useState(initial.date);
|
||||
const [time, setTime] = useState(initial.time);
|
||||
const [kind, setKind] = useState<RecurrenceKind>(initialKind);
|
||||
const [weeklyDays, setWeeklyDays] = useState<number[]>(initialWeeklyDays);
|
||||
const [kind, setKind] = useState<RecurrenceKind>(initialSpec?.kind ?? "none");
|
||||
const [interval, setIntervalValue] = useState<number>(initialSpec?.interval ?? 1);
|
||||
const [weeklyDays, setWeeklyDays] = useState<number[]>(initialSpec?.weeklyDays ?? []);
|
||||
const [monthDay, setMonthDay] = useState<number | "">(
|
||||
initialSpec?.monthDay ?? "",
|
||||
);
|
||||
const [endKind, setEndKind] = useState<EndKind>(initialSpec?.end.kind ?? "never");
|
||||
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 [error, setError] = useState<string | null>(null);
|
||||
|
||||
function toggleWeekday(iso: number) {
|
||||
@ -68,21 +83,55 @@ export function WhenFormClient({
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
function handleContinue() {
|
||||
if (!date || !time) {
|
||||
setError("Pick both a date and a time.");
|
||||
const v = validateScheduledAt(date, time, timezone, Date.now());
|
||||
if (!v.ok) {
|
||||
const map: Record<typeof v.reason, string> = {
|
||||
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.",
|
||||
};
|
||||
setError(map[v.reason]);
|
||||
return;
|
||||
}
|
||||
const dt = DateTime.fromFormat(`${date} ${time}`, "yyyy-MM-dd HH:mm", { zone: timezone });
|
||||
if (!dt.isValid) {
|
||||
setError("Invalid date or time.");
|
||||
const dt = v.dt;
|
||||
if (endKind === "on" && !endUntil) {
|
||||
setError("Pick the end date for this recurrence.");
|
||||
return;
|
||||
}
|
||||
if (dt.toMillis() <= Date.now()) {
|
||||
setError("The first occurrence is in the past. Pick a future date and time.");
|
||||
if (endKind === "on" && endUntil) {
|
||||
const until = DateTime.fromISO(endUntil, { zone: timezone });
|
||||
if (until.isValid && until.toMillis() <= dt.toMillis()) {
|
||||
setError("The end date must be after the first fire.");
|
||||
return;
|
||||
}
|
||||
const rrule = buildRrule(kind, dt, weeklyDays);
|
||||
}
|
||||
if (endKind === "after" && (!Number.isFinite(endCount) || endCount < 1)) {
|
||||
setError("Number of occurrences must be at least 1.");
|
||||
return;
|
||||
}
|
||||
const spec = buildSpec(dt);
|
||||
const rrule = buildRrule(spec, dt);
|
||||
const scheduledAt = dt.toISO()!;
|
||||
const sp = new URLSearchParams({
|
||||
step: "4",
|
||||
@ -94,10 +143,21 @@ export function WhenFormClient({
|
||||
if (passThroughParams.text) sp.set("text", passThroughParams.text);
|
||||
if (passThroughParams.mediaId) sp.set("mediaId", passThroughParams.mediaId);
|
||||
if (passThroughParams.caption) sp.set("caption", passThroughParams.caption);
|
||||
if (passThroughParams.editReminderId) sp.set("editReminderId", passThroughParams.editReminderId);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
router.push(`/reminders/new?${sp.toString()}` as any);
|
||||
}
|
||||
|
||||
// Live preview text — uses the parsed first-fire if valid, else the date input alone.
|
||||
const previewDt = (() => {
|
||||
if (!date || !time) return null;
|
||||
const d = DateTime.fromFormat(`${date} ${time}`, "yyyy-MM-dd HH:mm", { zone: timezone });
|
||||
return d.isValid ? d : null;
|
||||
})();
|
||||
const previewSpec = previewDt ? buildSpec(previewDt) : null;
|
||||
const previewSentence =
|
||||
previewDt && previewSpec ? describeRecurrence(previewSpec, previewDt) : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Date + time */}
|
||||
@ -136,7 +196,7 @@ export function WhenFormClient({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recurrence */}
|
||||
{/* Frequency */}
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-1.5">
|
||||
<RepeatIcon className="size-3.5" />
|
||||
@ -165,10 +225,37 @@ export function WhenFormClient({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weekday picker — only for weekly */}
|
||||
{/* Recurrence detail — interval, weekdays, monthday, end */}
|
||||
{kind !== "none" && (
|
||||
<div className="space-y-4 rounded-xl border border-border bg-muted/30 p-4">
|
||||
{/* Interval */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Label htmlFor="recur-interval" className="text-sm">
|
||||
Every
|
||||
</Label>
|
||||
<Input
|
||||
id="recur-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>
|
||||
|
||||
{/* Weekly days */}
|
||||
{kind === "weekly" && (
|
||||
<div className="space-y-2">
|
||||
<Label>Days of the week</Label>
|
||||
<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);
|
||||
@ -182,7 +269,7 @@ export function WhenFormClient({
|
||||
"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-muted/50 hover:border-primary/50 hover:bg-primary/5 hover:text-primary",
|
||||
: "border-border bg-background hover:border-primary/50 hover:bg-primary/5 hover:text-primary",
|
||||
)}
|
||||
>
|
||||
{short}
|
||||
@ -196,6 +283,105 @@ export function WhenFormClient({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Monthly day-of-month */}
|
||||
{kind === "monthly" && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="recur-monthday" className="text-sm">
|
||||
Day of the month
|
||||
</Label>
|
||||
<Input
|
||||
id="recur-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);
|
||||
}}
|
||||
placeholder={String((previewDt ?? DateTime.now()).day)}
|
||||
className="h-8 w-24"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Months without this day skip naturally (e.g. 31st).
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* End condition */}
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Live preview */}
|
||||
{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" />
|
||||
|
||||
115
apps/web/src/lib/date-picker.test.ts
Normal file
115
apps/web/src/lib/date-picker.test.ts
Normal file
@ -0,0 +1,115 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { DateTime } from "luxon";
|
||||
import {
|
||||
splitDateTime,
|
||||
combineDateTime,
|
||||
validateScheduledAt,
|
||||
defaultFirstFireIso,
|
||||
} from "./date-picker";
|
||||
|
||||
const TZ = "Asia/Kuala_Lumpur";
|
||||
|
||||
describe("splitDateTime", () => {
|
||||
it("splits a zoned ISO into date + time strings in that zone", () => {
|
||||
// 09:00 KL is the same wall-clock no matter what offset is on the ISO.
|
||||
expect(splitDateTime("2026-05-13T09:00:00+08:00", TZ)).toEqual({
|
||||
date: "2026-05-13",
|
||||
time: "09:00",
|
||||
});
|
||||
});
|
||||
|
||||
it("converts a UTC ISO into the operator's local wall-clock", () => {
|
||||
// 2026-05-13 01:00Z = 09:00 KL.
|
||||
expect(splitDateTime("2026-05-13T01:00:00Z", TZ)).toEqual({
|
||||
date: "2026-05-13",
|
||||
time: "09:00",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns empty strings on malformed input", () => {
|
||||
expect(splitDateTime("not-an-iso", TZ)).toEqual({ date: "", time: "" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("combineDateTime", () => {
|
||||
it("returns null when either field is missing", () => {
|
||||
expect(combineDateTime("", "09:00", TZ)).toBe(null);
|
||||
expect(combineDateTime("2026-05-13", "", TZ)).toBe(null);
|
||||
});
|
||||
|
||||
it("parses a valid pair into a luxon DateTime in the right zone", () => {
|
||||
const dt = combineDateTime("2026-05-13", "09:00", TZ);
|
||||
expect(dt).not.toBeNull();
|
||||
expect(dt!.zoneName).toBe(TZ);
|
||||
// Use the offset format (timezone display varies by ICU build).
|
||||
expect(dt!.toFormat("yyyy-MM-dd HH:mm ZZ")).toBe("2026-05-13 09:00 +08:00");
|
||||
});
|
||||
|
||||
it("returns null for an unparseable pair", () => {
|
||||
expect(combineDateTime("2026-99-99", "09:00", TZ)).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateScheduledAt", () => {
|
||||
// Pin "now" so these tests are deterministic. 2026-05-13 09:00 KL.
|
||||
const NOW = DateTime.fromISO("2026-05-13T09:00:00", { zone: TZ }).toMillis();
|
||||
|
||||
it("rejects when the date is missing", () => {
|
||||
expect(validateScheduledAt("", "09:30", TZ, NOW)).toEqual({ ok: false, reason: "missing" });
|
||||
});
|
||||
|
||||
it("rejects when the time is missing", () => {
|
||||
expect(validateScheduledAt("2026-05-13", "", TZ, NOW)).toEqual({ ok: false, reason: "missing" });
|
||||
});
|
||||
|
||||
it("rejects an invalid date+time pair", () => {
|
||||
expect(validateScheduledAt("2026-99-99", "09:30", TZ, NOW)).toEqual({
|
||||
ok: false,
|
||||
reason: "invalid",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects timestamps clearly in the past", () => {
|
||||
expect(validateScheduledAt("2026-05-13", "07:00", TZ, NOW)).toEqual({
|
||||
ok: false,
|
||||
reason: "past",
|
||||
});
|
||||
});
|
||||
|
||||
it("bumps a same-minute time forward by one minute (the user clicked too fast)", () => {
|
||||
const r = validateScheduledAt("2026-05-13", "09:00", TZ, NOW);
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
expect(r.dt.toFormat("HH:mm")).toBe("09:01");
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts any future time as-is", () => {
|
||||
const r = validateScheduledAt("2026-05-13", "10:30", TZ, NOW);
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
expect(r.dt.toFormat("HH:mm")).toBe("10:30");
|
||||
}
|
||||
});
|
||||
|
||||
it("respects a custom grace window", () => {
|
||||
// 30 seconds in the past, grace = 0 → reject.
|
||||
expect(validateScheduledAt("2026-05-13", "08:59", TZ, NOW + 0, 0)).toEqual({
|
||||
ok: false,
|
||||
reason: "past",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultFirstFireIso", () => {
|
||||
it("rounds 'now' down to the start of the minute in the operator zone", () => {
|
||||
const now = DateTime.fromISO("2026-05-13T09:42:37.500", { zone: "UTC" });
|
||||
const iso = defaultFirstFireIso(TZ, now);
|
||||
const back = DateTime.fromISO(iso, { zone: TZ });
|
||||
expect(back.zoneName).toBe(TZ);
|
||||
expect(back.second).toBe(0);
|
||||
expect(back.millisecond).toBe(0);
|
||||
// 09:42:37 UTC = 17:42:37 KL → start of minute = 17:42 KL.
|
||||
expect(back.toFormat("HH:mm")).toBe("17:42");
|
||||
});
|
||||
});
|
||||
72
apps/web/src/lib/date-picker.ts
Normal file
72
apps/web/src/lib/date-picker.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
/**
|
||||
* Pure helpers for the wizard's date+time picker. Extracted so they're
|
||||
* exercisable from tests without spinning up a DOM.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Split an ISO timestamp into the `<input type="date">` and
|
||||
* `<input type="time">` strings the picker uses, interpreted in the given
|
||||
* timezone. Returns empty strings for an invalid ISO so the React inputs
|
||||
* stay controlled but blank.
|
||||
*/
|
||||
export function splitDateTime(iso: string, tz: string): { date: string; time: string } {
|
||||
const dt = DateTime.fromISO(iso, { zone: tz });
|
||||
if (!dt.isValid) return { date: "", time: "" };
|
||||
return { date: dt.toFormat("yyyy-MM-dd"), time: dt.toFormat("HH:mm") };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the picker fields back into a luxon DateTime in the operator's zone.
|
||||
* Returns null if either field is empty or the combination doesn't parse.
|
||||
*/
|
||||
export function combineDateTime(date: string, time: string, tz: string): DateTime | null {
|
||||
if (!date || !time) return null;
|
||||
const dt = DateTime.fromFormat(`${date} ${time}`, "yyyy-MM-dd HH:mm", { zone: tz });
|
||||
return dt.isValid ? dt : null;
|
||||
}
|
||||
|
||||
export type DateTimeValidation =
|
||||
| { ok: true; dt: DateTime }
|
||||
| { ok: false; reason: "missing" | "invalid" | "past" };
|
||||
|
||||
/**
|
||||
* Validate a (date, time) pair as a "first occurrence" timestamp.
|
||||
*
|
||||
* The default value the wizard pre-fills is the *current minute* in the
|
||||
* operator's timezone. The user might click Continue immediately, before
|
||||
* that minute ticks over — `dt` would be ≤ now by a few seconds, but the
|
||||
* intent is "now". This helper bumps anything within `nowGraceMs` of now
|
||||
* to the next minute rather than rejecting it. Beyond that grace window
|
||||
* we treat it as a real "in the past" mistake.
|
||||
*
|
||||
* `now` is injected so tests can pin it.
|
||||
*/
|
||||
export function validateScheduledAt(
|
||||
date: string,
|
||||
time: string,
|
||||
tz: string,
|
||||
now: number,
|
||||
nowGraceMs = 60_000,
|
||||
): DateTimeValidation {
|
||||
if (!date || !time) return { ok: false, reason: "missing" };
|
||||
const dt = combineDateTime(date, time, tz);
|
||||
if (!dt) return { ok: false, reason: "invalid" };
|
||||
if (dt.toMillis() <= now) {
|
||||
if (now - dt.toMillis() <= nowGraceMs) {
|
||||
return { ok: true, dt: dt.plus({ minutes: 1 }) };
|
||||
}
|
||||
return { ok: false, reason: "past" };
|
||||
}
|
||||
return { ok: true, dt };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the wizard's default first-fire ISO. Server-rendered so SSR and
|
||||
* client first-render agree (no hydration mismatch from a stray `Date.now()`
|
||||
* inside the client component).
|
||||
*/
|
||||
export function defaultFirstFireIso(tz: string, now: DateTime = DateTime.now()): string {
|
||||
return now.setZone(tz).startOf("minute").toISO()!;
|
||||
}
|
||||
@ -93,7 +93,7 @@ export async function getGroup(operatorId: string, groupId: string) {
|
||||
export async function listReminders(operatorId: string) {
|
||||
const rows = await db.execute(sql`
|
||||
SELECT
|
||||
r.id, r.name, r.schedule_kind, r.scheduled_at, 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,
|
||||
(SELECT count(*) FROM reminder_targets rt WHERE rt.reminder_id = r.id) as group_count
|
||||
FROM reminders r
|
||||
@ -107,6 +107,7 @@ export async function listReminders(operatorId: string) {
|
||||
name: r.name as string,
|
||||
scheduleKind: r.schedule_kind as string,
|
||||
scheduledAt: r.scheduled_at as Date | null,
|
||||
rrule: (r.rrule as string | null) ?? null,
|
||||
timezone: r.timezone as string,
|
||||
status: r.status as string,
|
||||
createdAt: r.created_at as Date,
|
||||
|
||||
@ -4,64 +4,207 @@ import {
|
||||
buildRrule,
|
||||
describeRecurrence,
|
||||
kindFromRrule,
|
||||
specFromRrule,
|
||||
type RecurrenceSpec,
|
||||
} from "./recurrence";
|
||||
|
||||
const FIRST = DateTime.fromISO("2026-05-13T09:00:00", { zone: "Asia/Kuala_Lumpur" });
|
||||
|
||||
const baseSpec = (over: Partial<RecurrenceSpec> = {}): RecurrenceSpec => ({
|
||||
kind: "none",
|
||||
interval: 1,
|
||||
weeklyDays: [],
|
||||
end: { kind: "never" },
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("buildRrule", () => {
|
||||
it("returns null for one-off", () => {
|
||||
expect(buildRrule("none", FIRST, [])).toBe(null);
|
||||
expect(buildRrule(baseSpec({ kind: "none" }), FIRST)).toBe(null);
|
||||
});
|
||||
|
||||
it("daily → FREQ=DAILY", () => {
|
||||
expect(buildRrule("daily", FIRST, [])).toBe("FREQ=DAILY");
|
||||
it("daily simple", () => {
|
||||
expect(buildRrule(baseSpec({ kind: "daily" }), FIRST)).toBe("FREQ=DAILY");
|
||||
});
|
||||
|
||||
it("weekly with explicit days uses BYDAY in MO,TU,WE,TH,FR,SA,SU order", () => {
|
||||
// Pass days in mixed order — should be sorted by ISO weekday number
|
||||
expect(buildRrule("weekly", FIRST, [3, 1, 5])).toBe("FREQ=WEEKLY;BYDAY=MO,WE,FR");
|
||||
it("daily with interval", () => {
|
||||
expect(buildRrule(baseSpec({ kind: "daily", interval: 3 }), FIRST)).toBe(
|
||||
"FREQ=DAILY;INTERVAL=3",
|
||||
);
|
||||
});
|
||||
|
||||
it("weekly with no days falls back to first-fire weekday", () => {
|
||||
// 2026-05-13 is a Wednesday in luxon ISO weekday → 3
|
||||
expect(buildRrule("weekly", FIRST, [])).toBe("FREQ=WEEKLY;BYDAY=WE");
|
||||
it("weekly with explicit days sorts to canonical order", () => {
|
||||
expect(
|
||||
buildRrule(baseSpec({ kind: "weekly", weeklyDays: [3, 1, 5] }), FIRST),
|
||||
).toBe("FREQ=WEEKLY;BYDAY=MO,WE,FR");
|
||||
});
|
||||
|
||||
it("monthly uses BYMONTHDAY of the first-fire date", () => {
|
||||
expect(buildRrule("monthly", FIRST, [])).toBe("FREQ=MONTHLY;BYMONTHDAY=13");
|
||||
it("weekly with no days falls back to first-fire weekday (Wed)", () => {
|
||||
expect(buildRrule(baseSpec({ kind: "weekly" }), FIRST)).toBe("FREQ=WEEKLY;BYDAY=WE");
|
||||
});
|
||||
|
||||
it("monthly defaults to first-fire day-of-month", () => {
|
||||
expect(buildRrule(baseSpec({ kind: "monthly" }), FIRST)).toBe(
|
||||
"FREQ=MONTHLY;BYMONTHDAY=13",
|
||||
);
|
||||
});
|
||||
|
||||
it("monthly honours explicit monthDay", () => {
|
||||
expect(buildRrule(baseSpec({ kind: "monthly", monthDay: 1 }), FIRST)).toBe(
|
||||
"FREQ=MONTHLY;BYMONTHDAY=1",
|
||||
);
|
||||
});
|
||||
|
||||
it("yearly uses BYMONTH and BYMONTHDAY", () => {
|
||||
expect(buildRrule("yearly", FIRST, [])).toBe("FREQ=YEARLY;BYMONTH=5;BYMONTHDAY=13");
|
||||
expect(buildRrule(baseSpec({ kind: "yearly" }), FIRST)).toBe(
|
||||
"FREQ=YEARLY;BYMONTH=5;BYMONTHDAY=13",
|
||||
);
|
||||
});
|
||||
|
||||
it("end=after attaches COUNT", () => {
|
||||
expect(
|
||||
buildRrule(
|
||||
baseSpec({ kind: "daily", end: { kind: "after", count: 7 } }),
|
||||
FIRST,
|
||||
),
|
||||
).toBe("FREQ=DAILY;COUNT=7");
|
||||
});
|
||||
|
||||
it("end=on attaches UNTIL in UTC", () => {
|
||||
const r = buildRrule(
|
||||
baseSpec({ kind: "daily", end: { kind: "on", until: "2026-06-01" } }),
|
||||
FIRST,
|
||||
);
|
||||
expect(r).toMatch(/^FREQ=DAILY;UNTIL=2026060[0-2]T235959Z$/);
|
||||
});
|
||||
|
||||
it("interval + weekly + count compose correctly", () => {
|
||||
expect(
|
||||
buildRrule(
|
||||
baseSpec({
|
||||
kind: "weekly",
|
||||
interval: 2,
|
||||
weeklyDays: [1, 3, 5],
|
||||
end: { kind: "after", count: 12 },
|
||||
}),
|
||||
FIRST,
|
||||
),
|
||||
).toBe("FREQ=WEEKLY;BYDAY=MO,WE,FR;INTERVAL=2;COUNT=12");
|
||||
});
|
||||
});
|
||||
|
||||
describe("kindFromRrule", () => {
|
||||
it("recognises every supported FREQ", () => {
|
||||
expect(kindFromRrule(null)).toBe("none");
|
||||
describe("specFromRrule / kindFromRrule", () => {
|
||||
it("returns the default spec for null/undefined", () => {
|
||||
expect(specFromRrule(null)).toEqual({
|
||||
kind: "none",
|
||||
interval: 1,
|
||||
weeklyDays: [],
|
||||
monthDay: undefined,
|
||||
end: { kind: "never" },
|
||||
});
|
||||
expect(kindFromRrule(undefined)).toBe("none");
|
||||
expect(kindFromRrule("FREQ=DAILY")).toBe("daily");
|
||||
expect(kindFromRrule("FREQ=WEEKLY;BYDAY=MO,WE,FR")).toBe("weekly");
|
||||
expect(kindFromRrule("FREQ=MONTHLY;BYMONTHDAY=13")).toBe("monthly");
|
||||
expect(kindFromRrule("FREQ=YEARLY;BYMONTH=5;BYMONTHDAY=13")).toBe("yearly");
|
||||
});
|
||||
|
||||
it("is case-insensitive", () => {
|
||||
expect(kindFromRrule("freq=daily")).toBe("daily");
|
||||
it("parses daily with interval", () => {
|
||||
expect(specFromRrule("FREQ=DAILY;INTERVAL=3")).toEqual({
|
||||
kind: "daily",
|
||||
interval: 3,
|
||||
weeklyDays: [],
|
||||
monthDay: undefined,
|
||||
end: { kind: "never" },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 'none' for an unrecognised rule", () => {
|
||||
expect(kindFromRrule("FREQ=HOURLY")).toBe("none");
|
||||
it("parses weekly with BYDAY", () => {
|
||||
expect(specFromRrule("FREQ=WEEKLY;BYDAY=MO,WE,FR")).toMatchObject({
|
||||
kind: "weekly",
|
||||
weeklyDays: [1, 3, 5],
|
||||
});
|
||||
});
|
||||
|
||||
it("parses monthly with BYMONTHDAY", () => {
|
||||
expect(specFromRrule("FREQ=MONTHLY;BYMONTHDAY=15")).toMatchObject({
|
||||
kind: "monthly",
|
||||
monthDay: 15,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses COUNT into end=after", () => {
|
||||
expect(specFromRrule("FREQ=DAILY;COUNT=10").end).toEqual({
|
||||
kind: "after",
|
||||
count: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses UNTIL into end=on (date only)", () => {
|
||||
expect(specFromRrule("FREQ=DAILY;UNTIL=20260601T235959Z").end).toEqual({
|
||||
kind: "on",
|
||||
until: "2026-06-01",
|
||||
});
|
||||
});
|
||||
|
||||
it("round-trips through buildRrule + specFromRrule for compound rules", () => {
|
||||
const spec = baseSpec({
|
||||
kind: "weekly",
|
||||
interval: 2,
|
||||
weeklyDays: [1, 3, 5],
|
||||
end: { kind: "after", count: 12 },
|
||||
});
|
||||
const rule = buildRrule(spec, FIRST)!;
|
||||
expect(specFromRrule(rule)).toMatchObject({
|
||||
kind: "weekly",
|
||||
interval: 2,
|
||||
weeklyDays: [1, 3, 5],
|
||||
end: { kind: "after", count: 12 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeRecurrence", () => {
|
||||
it("renders human-readable summaries", () => {
|
||||
expect(describeRecurrence("none", FIRST, [])).toBe("One-off");
|
||||
expect(describeRecurrence("daily", FIRST, [])).toBe("Every day");
|
||||
expect(describeRecurrence("weekly", FIRST, [1, 3, 5])).toBe("Every Mon, Wed, Fri");
|
||||
expect(describeRecurrence("weekly", FIRST, [])).toBe("Every Wed");
|
||||
expect(describeRecurrence("monthly", FIRST, [])).toBe("Every month on day 13");
|
||||
expect(describeRecurrence("yearly", FIRST, [])).toBe("Every year on May 13");
|
||||
it("renders a one-off label", () => {
|
||||
expect(describeRecurrence(baseSpec({ kind: "none" }), FIRST)).toBe("One-off");
|
||||
});
|
||||
|
||||
it("renders interval and unit pluralisation", () => {
|
||||
expect(describeRecurrence(baseSpec({ kind: "daily" }), FIRST)).toBe("Every day");
|
||||
expect(describeRecurrence(baseSpec({ kind: "daily", interval: 2 }), FIRST)).toBe(
|
||||
"Every 2 days",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders weekly days as Short labels in canonical order", () => {
|
||||
expect(
|
||||
describeRecurrence(baseSpec({ kind: "weekly", weeklyDays: [5, 1, 3] }), FIRST),
|
||||
).toBe("Every week on Mon, Wed, Fri");
|
||||
});
|
||||
|
||||
it("renders monthly with day", () => {
|
||||
expect(describeRecurrence(baseSpec({ kind: "monthly", monthDay: 14 }), FIRST)).toBe(
|
||||
"Every month on day 14",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders yearly with month and day", () => {
|
||||
expect(describeRecurrence(baseSpec({ kind: "yearly" }), FIRST)).toBe(
|
||||
"Every year on May 13",
|
||||
);
|
||||
});
|
||||
|
||||
it("appends end=after as ', N times'", () => {
|
||||
expect(
|
||||
describeRecurrence(
|
||||
baseSpec({ kind: "daily", end: { kind: "after", count: 5 } }),
|
||||
FIRST,
|
||||
),
|
||||
).toBe("Every day, 5 times");
|
||||
});
|
||||
|
||||
it("appends end=on as ', until <date>'", () => {
|
||||
expect(
|
||||
describeRecurrence(
|
||||
baseSpec({ kind: "daily", end: { kind: "on", until: "2026-06-01" } }),
|
||||
FIRST,
|
||||
),
|
||||
).toBe("Every day, until 2026-06-01");
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
export type RecurrenceKind = "none" | "daily" | "weekly" | "monthly" | "yearly";
|
||||
export type EndKind = "never" | "after" | "on";
|
||||
|
||||
/** ISO weekday → RRULE day code. Luxon weekday: 1=Mon ... 7=Sun. */
|
||||
const WEEKDAY_CODES = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"] as const;
|
||||
|
||||
export const WEEKDAY_LABELS: Array<{ iso: number; code: string; short: string; long: string }> = [
|
||||
@ -15,77 +15,182 @@ export const WEEKDAY_LABELS: Array<{ iso: number; code: string; short: string; l
|
||||
{ iso: 7, code: "SU", short: "Sun", long: "Sunday" },
|
||||
];
|
||||
|
||||
export interface RecurrenceSpec {
|
||||
kind: RecurrenceKind;
|
||||
/** Every N units. Defaults to 1. Ignored for `none`. */
|
||||
interval: number;
|
||||
/** ISO weekday numbers (1=Mon..7=Sun). Used for `weekly`. */
|
||||
weeklyDays: number[];
|
||||
/** Day-of-month for `monthly` (1-31). If omitted, falls back to firstFire.day. */
|
||||
monthDay?: number;
|
||||
/** End condition. */
|
||||
end:
|
||||
| { kind: "never" }
|
||||
| { kind: "after"; count: number }
|
||||
| { kind: "on"; until: string /* ISO date YYYY-MM-DD */ };
|
||||
}
|
||||
|
||||
export const DEFAULT_RECURRENCE: RecurrenceSpec = {
|
||||
kind: "none",
|
||||
interval: 1,
|
||||
weeklyDays: [],
|
||||
end: { kind: "never" },
|
||||
};
|
||||
|
||||
function clampInterval(n: number): number {
|
||||
if (!Number.isFinite(n) || n < 1) return 1;
|
||||
return Math.floor(n);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an RRULE for the given recurrence pattern. Returns null for "none"
|
||||
* (one-off reminders don't carry an RRULE).
|
||||
*
|
||||
* For weekly with no weekdays selected, falls back to the weekday of
|
||||
* `firstFire` so the rule is always concrete.
|
||||
* Build an RRULE string. Supports interval, weekday list, monthday, and the
|
||||
* end condition (COUNT or UNTIL). Returns null for one-off reminders.
|
||||
*/
|
||||
export function buildRrule(
|
||||
kind: RecurrenceKind,
|
||||
firstFire: DateTime,
|
||||
weeklyDays: number[],
|
||||
): string | null {
|
||||
switch (kind) {
|
||||
case "none":
|
||||
return null;
|
||||
export function buildRrule(spec: RecurrenceSpec, firstFire: DateTime): string | null {
|
||||
if (spec.kind === "none") return null;
|
||||
|
||||
const parts: string[] = [];
|
||||
switch (spec.kind) {
|
||||
case "daily":
|
||||
return "FREQ=DAILY";
|
||||
parts.push("FREQ=DAILY");
|
||||
break;
|
||||
case "weekly": {
|
||||
parts.push("FREQ=WEEKLY");
|
||||
const days =
|
||||
weeklyDays.length > 0
|
||||
? weeklyDays
|
||||
spec.weeklyDays.length > 0
|
||||
? spec.weeklyDays
|
||||
: [firstFire.weekday];
|
||||
const codes = days
|
||||
.slice()
|
||||
.sort((a, b) => a - b)
|
||||
.map((d) => WEEKDAY_CODES[d - 1])
|
||||
.filter(Boolean);
|
||||
return `FREQ=WEEKLY;BYDAY=${codes.join(",")}`;
|
||||
parts.push(`BYDAY=${codes.join(",")}`);
|
||||
break;
|
||||
}
|
||||
case "monthly":
|
||||
return `FREQ=MONTHLY;BYMONTHDAY=${firstFire.day}`;
|
||||
parts.push("FREQ=MONTHLY");
|
||||
parts.push(`BYMONTHDAY=${spec.monthDay ?? firstFire.day}`);
|
||||
break;
|
||||
case "yearly":
|
||||
return `FREQ=YEARLY;BYMONTH=${firstFire.month};BYMONTHDAY=${firstFire.day}`;
|
||||
parts.push("FREQ=YEARLY");
|
||||
parts.push(`BYMONTH=${firstFire.month}`);
|
||||
parts.push(`BYMONTHDAY=${firstFire.day}`);
|
||||
break;
|
||||
}
|
||||
|
||||
const interval = clampInterval(spec.interval);
|
||||
if (interval !== 1) parts.push(`INTERVAL=${interval}`);
|
||||
|
||||
if (spec.end.kind === "after" && spec.end.count > 0) {
|
||||
parts.push(`COUNT=${Math.floor(spec.end.count)}`);
|
||||
} else if (spec.end.kind === "on" && spec.end.until) {
|
||||
// RRULE UNTIL is a UTC timestamp. Translate the user's "on this date"
|
||||
// into 23:59:59 UTC of that day so the last occurrence is included.
|
||||
const dt = DateTime.fromISO(`${spec.end.until}T23:59:59`, { zone: "utc" });
|
||||
if (dt.isValid) {
|
||||
parts.push(`UNTIL=${dt.toFormat("yyyyMMdd'T'HHmmss'Z'")}`);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join(";");
|
||||
}
|
||||
|
||||
/** Human-readable summary, e.g. "Every Mon, Wed" or "Every month on the 14th". */
|
||||
export function describeRecurrence(
|
||||
kind: RecurrenceKind,
|
||||
firstFire: DateTime,
|
||||
weeklyDays: number[],
|
||||
): string {
|
||||
switch (kind) {
|
||||
case "none":
|
||||
return "One-off";
|
||||
case "daily":
|
||||
return "Every day";
|
||||
case "weekly": {
|
||||
const days = weeklyDays.length > 0 ? weeklyDays : [firstFire.weekday];
|
||||
const FREQ_UNIT: Record<string, string> = {
|
||||
daily: "day",
|
||||
weekly: "week",
|
||||
monthly: "month",
|
||||
yearly: "year",
|
||||
};
|
||||
|
||||
/**
|
||||
* Render the spec as a human sentence, e.g.
|
||||
* "Every day"
|
||||
* "Every 2 weeks on Mon, Wed, Fri"
|
||||
* "Every month on day 14, 12 times"
|
||||
* "Every year on May 13, until 2027-05-13"
|
||||
*/
|
||||
export function describeRecurrence(spec: RecurrenceSpec, firstFire: DateTime): string {
|
||||
if (spec.kind === "none") return "One-off";
|
||||
|
||||
const interval = clampInterval(spec.interval);
|
||||
const unit = FREQ_UNIT[spec.kind]!;
|
||||
const head =
|
||||
interval === 1 ? `Every ${unit}` : `Every ${interval} ${unit}s`;
|
||||
|
||||
let body = "";
|
||||
if (spec.kind === "weekly") {
|
||||
const days = spec.weeklyDays.length > 0 ? spec.weeklyDays : [firstFire.weekday];
|
||||
const labels = days
|
||||
.slice()
|
||||
.sort((a, b) => a - b)
|
||||
.map((d) => WEEKDAY_LABELS[d - 1]?.short)
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
return `Every ${labels}`;
|
||||
body = ` on ${labels}`;
|
||||
} else if (spec.kind === "monthly") {
|
||||
body = ` on day ${spec.monthDay ?? firstFire.day}`;
|
||||
} else if (spec.kind === "yearly") {
|
||||
body = ` on ${firstFire.toFormat("MMM d")}`;
|
||||
}
|
||||
case "monthly":
|
||||
return `Every month on day ${firstFire.day}`;
|
||||
case "yearly":
|
||||
return `Every year on ${firstFire.toFormat("MMM d")}`;
|
||||
|
||||
let tail = "";
|
||||
if (spec.end.kind === "after" && spec.end.count > 0) {
|
||||
tail = `, ${Math.floor(spec.end.count)} time${spec.end.count === 1 ? "" : "s"}`;
|
||||
} else if (spec.end.kind === "on" && spec.end.until) {
|
||||
tail = `, until ${spec.end.until}`;
|
||||
}
|
||||
|
||||
return head + body + tail;
|
||||
}
|
||||
|
||||
/** Parse the kind back from an RRULE string (best-effort, for review display). */
|
||||
export function kindFromRrule(rrule: string | null | undefined): RecurrenceKind {
|
||||
if (!rrule) return "none";
|
||||
const upper = rrule.toUpperCase();
|
||||
if (upper.includes("FREQ=DAILY")) return "daily";
|
||||
if (upper.includes("FREQ=WEEKLY")) return "weekly";
|
||||
if (upper.includes("FREQ=MONTHLY")) return "monthly";
|
||||
if (upper.includes("FREQ=YEARLY")) return "yearly";
|
||||
return "none";
|
||||
/** Parse a stored RRULE back into a spec for resuming the wizard / editing. */
|
||||
export function specFromRrule(rrule: string | null | undefined): RecurrenceSpec {
|
||||
if (!rrule) return { ...DEFAULT_RECURRENCE };
|
||||
|
||||
const tokens = rrule
|
||||
.split(";")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
.reduce<Record<string, string>>((acc, t) => {
|
||||
const [k, v] = t.split("=");
|
||||
if (k && v !== undefined) acc[k.toUpperCase()] = v;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const freq = (tokens.FREQ ?? "").toUpperCase();
|
||||
let kind: RecurrenceKind = "none";
|
||||
if (freq === "DAILY") kind = "daily";
|
||||
else if (freq === "WEEKLY") kind = "weekly";
|
||||
else if (freq === "MONTHLY") kind = "monthly";
|
||||
else if (freq === "YEARLY") kind = "yearly";
|
||||
|
||||
const interval = tokens.INTERVAL ? clampInterval(Number(tokens.INTERVAL)) : 1;
|
||||
|
||||
const weeklyDays: number[] = [];
|
||||
if (tokens.BYDAY) {
|
||||
for (const code of tokens.BYDAY.split(",")) {
|
||||
const idx = WEEKDAY_CODES.indexOf(code.toUpperCase() as (typeof WEEKDAY_CODES)[number]);
|
||||
if (idx >= 0) weeklyDays.push(idx + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const monthDay = tokens.BYMONTHDAY ? Number(tokens.BYMONTHDAY) || undefined : undefined;
|
||||
|
||||
let end: RecurrenceSpec["end"] = { kind: "never" };
|
||||
if (tokens.COUNT) {
|
||||
const n = Number(tokens.COUNT);
|
||||
if (Number.isFinite(n) && n > 0) end = { kind: "after", count: Math.floor(n) };
|
||||
} else if (tokens.UNTIL) {
|
||||
// UNTIL is `YYYYMMDDTHHMMSSZ` per RFC. Pull the date.
|
||||
const m = tokens.UNTIL.match(/^(\d{4})(\d{2})(\d{2})/);
|
||||
if (m) end = { kind: "on", until: `${m[1]}-${m[2]}-${m[3]}` };
|
||||
}
|
||||
|
||||
return { kind, interval, weeklyDays, monthDay, end };
|
||||
}
|
||||
|
||||
/** Backwards-compatible helper for callers that only need the kind. */
|
||||
export function kindFromRrule(rrule: string | null | undefined): RecurrenceKind {
|
||||
return specFromRrule(rrule).kind;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user