feat: recurring reminders, fix QR pairing, account UX polish, tests
Reminders
- Add recurrence to wizard step 3 (None / Daily / Weekly+weekday picker /
Monthly / Yearly). Build the RRULE client-side and thread it through
the wizard URL state.
- Action stores rrule + scheduleKind="recurring" on insert.
- Bot reschedules the next occurrence after firing a recurring reminder
using the existing rrule helpers in @cmbot/shared. One-off behavior
unchanged.
- Add reminders.last_fired_at column to track last fire.
Pairing
- Move QR PNG out of the pg_notify payload (the 8000-byte limit was
silently truncating it; QR never reached the web → "QR hang"). PNG
now lives on whatsapp_accounts.last_qr_png; NOTIFY just signals
{type: session.qr, accountId, ts}. Web fetches the bytes from a new
read-only /api/qr/[accountId] route (allowed via middleware).
- handleStartPairing now stops any in-flight session before starting a
fresh one — fixes Re-pair where session.start was a silent no-op and
Baileys never re-emitted QR.
- Pair-live: countdown moved out from over the QR (it was overlapping
the scan area); shown as a discrete progress bar above the QR.
- Add a "Save QR" download button.
Account detail page
- Pair / Unpair / Delete cards are themselves the trigger (form submit
or DialogTrigger) — no inline buttons, whole card is clickable.
- Sync Groups Now card removed earlier; bot already auto-syncs.
Account list page
- Cards are the link target. A small floating Delete trigger (top-right
trash icon) opens the destructive confirm dialog without blocking
navigation on the rest of the card.
Tests
- recurrence.test.ts: 10 tests for buildRrule / kindFromRrule /
describeRecurrence (incl. weekly day combos and BYDAY ordering).
- reminders.schema.test.ts: regression for the "Invalid datetime" bug —
proves strict Zod .datetime() rejected luxon's offset ISO and the
{ offset: true } option accepts both forms.
Migration: 0004_next_prowler.sql
- whatsapp_accounts.last_qr_png (text)
- reminders.last_fired_at (timestamptz)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
86f2fe0124
commit
2b738383e4
@ -3,7 +3,10 @@ import { db } from "../db.js";
|
|||||||
import { logger } from "../logger.js";
|
import { logger } from "../logger.js";
|
||||||
|
|
||||||
export type WebEvent =
|
export type WebEvent =
|
||||||
| { type: "session.qr"; accountId: string; qrPng: string /* base64 */ }
|
// QR PNG bytes live in `whatsapp_accounts.last_qr_png` so this NOTIFY
|
||||||
|
// payload stays under Postgres' 8000-byte limit. Web fetches the PNG
|
||||||
|
// from /api/qr/[accountId] when it sees this event.
|
||||||
|
| { type: "session.qr"; accountId: string; ts: number }
|
||||||
| { type: "session.connected"; accountId: string; phoneNumber: string | null }
|
| { type: "session.connected"; accountId: string; phoneNumber: string | null }
|
||||||
| { type: "session.disconnected"; accountId: string }
|
| { type: "session.disconnected"; accountId: string }
|
||||||
| { type: "session.timeout"; accountId: string }
|
| { type: "session.timeout"; accountId: string }
|
||||||
|
|||||||
@ -51,6 +51,19 @@ export async function handleStartPairing(accountId: string): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.)
|
||||||
|
if (sessionManager.hasSession(accountId)) {
|
||||||
|
await sessionManager.stop(accountId);
|
||||||
|
}
|
||||||
|
// Clear any stale QR lingering from a prior attempt.
|
||||||
|
lastQrPayload.delete(accountId);
|
||||||
|
await db
|
||||||
|
.update(whatsappAccounts)
|
||||||
|
.set({ lastQrPng: null })
|
||||||
|
.where(eq(whatsappAccounts.id, accountId));
|
||||||
|
|
||||||
const off = sessionManager.on(async (id, _state, event) => {
|
const off = sessionManager.on(async (id, _state, event) => {
|
||||||
if (id !== accountId) return;
|
if (id !== accountId) return;
|
||||||
try {
|
try {
|
||||||
@ -58,10 +71,16 @@ export async function handleStartPairing(accountId: string): Promise<void> {
|
|||||||
if (lastQrPayload.get(id) === event.payload) return;
|
if (lastQrPayload.get(id) === event.payload) return;
|
||||||
lastQrPayload.set(id, event.payload);
|
lastQrPayload.set(id, event.payload);
|
||||||
const png = await renderQrPng(event.payload);
|
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].
|
||||||
|
await db
|
||||||
|
.update(whatsappAccounts)
|
||||||
|
.set({ lastQrPng: png.toString("base64"), lastQrAt: new Date() })
|
||||||
|
.where(eq(whatsappAccounts.id, id));
|
||||||
await pgNotifyWeb({
|
await pgNotifyWeb({
|
||||||
type: "session.qr",
|
type: "session.qr",
|
||||||
accountId: id,
|
accountId: id,
|
||||||
qrPng: png.toString("base64"),
|
ts: Date.now(),
|
||||||
});
|
});
|
||||||
} else if (event.type === "open") {
|
} else if (event.type === "open") {
|
||||||
const t = pairTimeouts.get(id);
|
const t = pairTimeouts.get(id);
|
||||||
|
|||||||
@ -4,10 +4,12 @@ import { db } from "../db.js";
|
|||||||
import { logger } from "../logger.js";
|
import { logger } from "../logger.js";
|
||||||
import { sessionManager } from "../whatsapp/session-manager.js";
|
import { sessionManager } from "../whatsapp/session-manager.js";
|
||||||
import { sendTextToGroup, sendMediaToGroup } from "../whatsapp/sender.js";
|
import { sendTextToGroup, sendMediaToGroup } from "../whatsapp/sender.js";
|
||||||
import { absoluteMediaPath } from "@cmbot/shared";
|
import { absoluteMediaPath, nextOccurrence } from "@cmbot/shared";
|
||||||
import { env } from "../env.js";
|
import { env } from "../env.js";
|
||||||
import { writeAuditLog } from "../audit.js";
|
import { writeAuditLog } from "../audit.js";
|
||||||
import { getReminderWithDetails } from "../reminders/crud.js";
|
import { getReminderWithDetails } from "../reminders/crud.js";
|
||||||
|
import { getBoss } from "./pgboss-client.js";
|
||||||
|
import { scheduleReminderFire } from "./reminder-jobs.js";
|
||||||
|
|
||||||
export type FireReminderPayload = { reminderId: string };
|
export type FireReminderPayload = { reminderId: string };
|
||||||
|
|
||||||
@ -119,14 +121,31 @@ export async function fireReminder(payload: FireReminderPayload): Promise<void>
|
|||||||
.set({ status })
|
.set({ status })
|
||||||
.where(eq(reminderRuns.id, runId));
|
.where(eq(reminderRuns.id, runId));
|
||||||
|
|
||||||
// One-off reminders are done after firing — flip them to 'ended' so the
|
// One-off reminders end after firing. Recurring reminders compute the
|
||||||
// menu shows ⚪ instead of 🟢. Recurring reminders stay 'active' (more
|
// next occurrence from the RRULE and re-arm the pg-boss job; only the
|
||||||
// occurrences pending; recurring is a future-plan feature).
|
// last fire timestamp + updatedAt move forward.
|
||||||
if (reminder.scheduleKind === "one_off") {
|
if (reminder.scheduleKind === "one_off") {
|
||||||
await db
|
await db
|
||||||
.update(reminders)
|
.update(reminders)
|
||||||
.set({ status: "ended", updatedAt: new Date() })
|
.set({ status: "ended", updatedAt: new Date() })
|
||||||
.where(eq(reminders.id, reminder.id));
|
.where(eq(reminders.id, reminder.id));
|
||||||
|
} else if (reminder.scheduleKind === "recurring" && reminder.rrule) {
|
||||||
|
const next = nextOccurrence(reminder.rrule, reminder.timezone, new Date());
|
||||||
|
await db
|
||||||
|
.update(reminders)
|
||||||
|
.set({ lastFiredAt: new Date(), updatedAt: new Date() })
|
||||||
|
.where(eq(reminders.id, reminder.id));
|
||||||
|
if (next) {
|
||||||
|
try {
|
||||||
|
await scheduleReminderFire(getBoss(), reminder.id, next);
|
||||||
|
logger.info({ reminderId: reminder.id, next }, "fire-reminder: re-armed for next occurrence");
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err, reminderId: reminder.id }, "fire-reminder: failed to re-arm next occurrence");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.info({ reminderId: reminder.id }, "fire-reminder: no further occurrences, ending");
|
||||||
|
await db.update(reminders).set({ status: "ended" }).where(eq(reminders.id, reminder.id));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await writeAuditLog(db, {
|
await writeAuditLog(db, {
|
||||||
|
|||||||
30
apps/web/src/actions/reminders.schema.test.ts
Normal file
30
apps/web/src/actions/reminders.schema.test.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { DateTime } from "luxon";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression test for the "Invalid datetime" error.
|
||||||
|
*
|
||||||
|
* Earlier the action used `z.string().datetime()` (strict — UTC `Z` only).
|
||||||
|
* Luxon's `dt.toISO()` produces an offset-suffixed form like
|
||||||
|
* `2026-05-10T09:00:00.000+08:00`, which the strict validator rejects.
|
||||||
|
* The fix uses `.datetime({ offset: true })`.
|
||||||
|
*
|
||||||
|
* If this test ever fails again it means the schema regressed and any
|
||||||
|
* Asia/Kuala_Lumpur reminder will be rejected at submit.
|
||||||
|
*/
|
||||||
|
describe("createReminderAction Zod schema (datetime validator)", () => {
|
||||||
|
const offsetIso = DateTime.fromISO("2026-05-10T09:00:00", { zone: "Asia/Kuala_Lumpur" }).toISO()!;
|
||||||
|
const utcIso = DateTime.fromISO("2026-05-10T09:00:00", { zone: "UTC" }).toISO()!;
|
||||||
|
|
||||||
|
it("strict .datetime() (no options) rejects offset-suffixed ISO — that was the bug", () => {
|
||||||
|
const strict = z.string().datetime();
|
||||||
|
expect(strict.safeParse(offsetIso).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(".datetime({ offset: true }) accepts both offset and UTC ISO — that's the fix", () => {
|
||||||
|
const lenient = z.string().datetime({ offset: true });
|
||||||
|
expect(lenient.safeParse(offsetIso).success).toBe(true);
|
||||||
|
expect(lenient.safeParse(utcIso).success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -55,6 +55,7 @@ const createReminderSchema = z
|
|||||||
// `.datetime({ offset: true })` accepts both UTC `Z` and zoned offsets
|
// `.datetime({ offset: true })` accepts both UTC `Z` and zoned offsets
|
||||||
// like `+08:00` (luxon's `toISO()` produces the offset form).
|
// like `+08:00` (luxon's `toISO()` produces the offset form).
|
||||||
scheduledAtIso: z.string().datetime({ offset: true }),
|
scheduledAtIso: z.string().datetime({ offset: true }),
|
||||||
|
rrule: z.string().nullable().optional(),
|
||||||
timezone: z.string().default(DEFAULT_TIMEZONE),
|
timezone: z.string().default(DEFAULT_TIMEZONE),
|
||||||
})
|
})
|
||||||
.refine((d) => Boolean(d.text?.trim()) || Boolean(d.mediaId), {
|
.refine((d) => Boolean(d.text?.trim()) || Boolean(d.mediaId), {
|
||||||
@ -74,7 +75,7 @@ export async function createReminderAction(
|
|||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input" };
|
return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input" };
|
||||||
}
|
}
|
||||||
const { accountId, groupIds, text, mediaId, caption, scheduledAtIso, timezone } = parsed.data;
|
const { accountId, groupIds, text, mediaId, caption, scheduledAtIso, rrule, timezone } = parsed.data;
|
||||||
|
|
||||||
const op = await getSeededOperator();
|
const op = await getSeededOperator();
|
||||||
const account = await db.query.whatsappAccounts.findFirst({
|
const account = await db.query.whatsappAccounts.findFirst({
|
||||||
@ -104,8 +105,9 @@ export async function createReminderAction(
|
|||||||
.values({
|
.values({
|
||||||
accountId,
|
accountId,
|
||||||
name: (text ?? caption ?? "Reminder").slice(0, 50),
|
name: (text ?? caption ?? "Reminder").slice(0, 50),
|
||||||
scheduleKind: "one_off",
|
scheduleKind: rrule ? "recurring" : "one_off",
|
||||||
scheduledAt,
|
scheduledAt,
|
||||||
|
rrule: rrule ?? null,
|
||||||
timezone,
|
timezone,
|
||||||
status: "active",
|
status: "active",
|
||||||
createdBy: op.id,
|
createdBy: op.id,
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
DatabaseIcon,
|
DatabaseIcon,
|
||||||
PowerIcon,
|
PowerIcon,
|
||||||
PowerOffIcon,
|
PowerOffIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@ -17,7 +18,6 @@ import {
|
|||||||
CardContent,
|
CardContent,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
CardDescription,
|
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@ -52,7 +52,6 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-4 py-6 sm:px-6 sm:py-8 max-w-2xl mx-auto space-y-6">
|
<div className="px-4 py-6 sm:px-6 sm:py-8 max-w-2xl mx-auto space-y-6">
|
||||||
{/* Back link */}
|
|
||||||
<Button asChild variant="ghost" size="sm" className="-ml-2">
|
<Button asChild variant="ghost" size="sm" className="-ml-2">
|
||||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||||
<Link href={"/accounts" as any}>
|
<Link href={"/accounts" as any}>
|
||||||
@ -61,7 +60,6 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
|
|||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">{account.label}</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">{account.label}</h1>
|
||||||
@ -75,11 +73,16 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{/* Pair / Re-pair — visible when not currently connected */}
|
{/* Pair / Re-pair — entire card is the submit button */}
|
||||||
{account.status !== "connected" && (
|
{account.status !== "connected" && (
|
||||||
<Card>
|
<form action={pairAccountAction} className="contents">
|
||||||
|
<input type="hidden" name="accountId" value={account.id} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="block w-full text-left rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
|
>
|
||||||
|
<Card className="transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer">
|
||||||
<CardContent className="flex items-center justify-between gap-4 py-4">
|
<CardContent className="flex items-center justify-between gap-4 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex size-9 items-center justify-center rounded-lg bg-emerald-500/10">
|
<div className="flex size-9 items-center justify-center rounded-lg bg-emerald-500/10">
|
||||||
@ -94,24 +97,19 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form action={pairAccountAction}>
|
<ChevronRightIcon className="size-4 text-muted-foreground/60" />
|
||||||
<input type="hidden" name="accountId" value={account.id} />
|
|
||||||
<Button type="submit" size="sm">
|
|
||||||
<PowerIcon />
|
|
||||||
{account.status === "unpaired" ? "Pair Now" : "Re-pair"}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Groups + Sync — visible when connected */}
|
|
||||||
{account.status === "connected" && (
|
{account.status === "connected" && (
|
||||||
<>
|
<>
|
||||||
<Link
|
<Link
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
href={`/accounts/${account.id}/groups` as any}
|
href={`/accounts/${account.id}/groups` as any}
|
||||||
className="block focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl"
|
className="block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
>
|
>
|
||||||
<Card className="transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer">
|
<Card className="transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer">
|
||||||
<CardContent className="flex items-center justify-between gap-4 py-4">
|
<CardContent className="flex items-center justify-between gap-4 py-4">
|
||||||
@ -124,11 +122,19 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
|
|||||||
<p className="text-xs text-muted-foreground">View synced WhatsApp groups</p>
|
<p className="text-xs text-muted-foreground">View synced WhatsApp groups</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<ChevronRightIcon className="size-4 text-muted-foreground/60" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Card>
|
{/* Unpair — entire card opens the confirm dialog */}
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="block w-full text-left rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
|
>
|
||||||
|
<Card className="transition-all hover:shadow-md hover:ring-amber-500/30 cursor-pointer">
|
||||||
<CardContent className="flex items-center justify-between gap-4 py-4">
|
<CardContent className="flex items-center justify-between gap-4 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex size-9 items-center justify-center rounded-lg bg-amber-500/10">
|
<div className="flex size-9 items-center justify-center rounded-lg bg-amber-500/10">
|
||||||
@ -141,12 +147,10 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Dialog>
|
<ChevronRightIcon className="size-4 text-muted-foreground/60" />
|
||||||
<DialogTrigger asChild>
|
</CardContent>
|
||||||
<Button variant="outline" size="sm">
|
</Card>
|
||||||
<PowerOffIcon />
|
</button>
|
||||||
Unpair
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
@ -168,13 +172,17 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Delete — always available */}
|
{/* Delete — entire card opens the confirm dialog */}
|
||||||
<Card>
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="block w-full text-left rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
|
>
|
||||||
|
<Card className="transition-all hover:shadow-md hover:ring-destructive/30 cursor-pointer">
|
||||||
<CardContent className="flex items-center justify-between gap-4 py-4">
|
<CardContent className="flex items-center justify-between gap-4 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex size-9 items-center justify-center rounded-lg bg-destructive/10">
|
<div className="flex size-9 items-center justify-center rounded-lg bg-destructive/10">
|
||||||
@ -187,12 +195,10 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Dialog>
|
<ChevronRightIcon className="size-4 text-muted-foreground/60" />
|
||||||
<DialogTrigger asChild>
|
</CardContent>
|
||||||
<Button variant="destructive" size="sm">
|
</Card>
|
||||||
<Trash2Icon />
|
</button>
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
@ -214,11 +220,8 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Detail grid */}
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { PlusIcon, SmartphoneIcon, CalendarIcon } from "lucide-react";
|
import { PlusIcon, SmartphoneIcon, CalendarIcon, Trash2Icon } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@ -7,9 +7,19 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
import { AccountStatusBadge } from "@/components/account-status-badge";
|
import { AccountStatusBadge } from "@/components/account-status-badge";
|
||||||
import { getSeededOperator } from "@/lib/operator";
|
import { getSeededOperator } from "@/lib/operator";
|
||||||
import { listAccounts } from "@/lib/queries";
|
import { listAccounts } from "@/lib/queries";
|
||||||
|
import { deleteAccountAction } from "@/actions/accounts";
|
||||||
|
|
||||||
export default async function AccountsPage() {
|
export default async function AccountsPage() {
|
||||||
const op = await getSeededOperator();
|
const op = await getSeededOperator();
|
||||||
@ -31,8 +41,8 @@ export default async function AccountsPage() {
|
|||||||
{accounts.length > 0 ? (
|
{accounts.length > 0 ? (
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{accounts.map((account) => (
|
{accounts.map((account) => (
|
||||||
|
<div key={account.id} className="relative">
|
||||||
<Link
|
<Link
|
||||||
key={account.id}
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
href={`/accounts/${account.id}` as any}
|
href={`/accounts/${account.id}` as any}
|
||||||
className="block focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl"
|
className="block focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl"
|
||||||
@ -40,7 +50,9 @@ export default async function AccountsPage() {
|
|||||||
<Card className="h-full transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer">
|
<Card className="h-full transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<CardTitle className="text-base leading-snug">{account.label}</CardTitle>
|
<CardTitle className="text-base leading-snug pr-8">
|
||||||
|
{account.label}
|
||||||
|
</CardTitle>
|
||||||
<AccountStatusBadge status={account.status} />
|
<AccountStatusBadge status={account.status} />
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@ -70,6 +82,39 @@ export default async function AccountsPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{/* Floating delete trigger — sits over the card without
|
||||||
|
blocking the link target on the rest of the surface. */}
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Delete ${account.label}`}
|
||||||
|
className="absolute right-2 top-2 z-10 flex size-7 items-center justify-center rounded-full bg-background/80 text-muted-foreground hover:bg-destructive/10 hover:text-destructive backdrop-blur-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive"
|
||||||
|
>
|
||||||
|
<Trash2Icon className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete this account?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
<strong>{account.label}</strong> and all its reminders, groups,
|
||||||
|
and history will be permanently removed. This cannot be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter showCloseButton>
|
||||||
|
<form action={deleteAccountAction}>
|
||||||
|
<input type="hidden" name="accountId" value={account.id} />
|
||||||
|
<Button type="submit" variant="destructive" size="sm">
|
||||||
|
<Trash2Icon />
|
||||||
|
Yes, delete
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
24
apps/web/src/app/api/qr/[accountId]/route.ts
Normal file
24
apps/web/src/app/api/qr/[accountId]/route.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
|
||||||
|
interface RouteContext {
|
||||||
|
params: Promise<{ accountId: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(_req: Request, ctx: RouteContext): Promise<Response> {
|
||||||
|
const { accountId } = await ctx.params;
|
||||||
|
const account = await db.query.whatsappAccounts.findFirst({
|
||||||
|
where: (a, { eq }) => eq(a.id, accountId),
|
||||||
|
columns: { lastQrPng: true },
|
||||||
|
});
|
||||||
|
if (!account?.lastQrPng) {
|
||||||
|
return new NextResponse("Not Found", { status: 404 });
|
||||||
|
}
|
||||||
|
const buf = Buffer.from(account.lastQrPng, "base64");
|
||||||
|
return new NextResponse(new Uint8Array(buf), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "image/png",
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -3,14 +3,14 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { CheckCircle2Icon, XCircleIcon, ScanLineIcon } from "lucide-react";
|
import { CheckCircle2Icon, XCircleIcon, ScanLineIcon, DownloadIcon } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { useEvents } from "@/hooks/use-events";
|
import { useEvents } from "@/hooks/use-events";
|
||||||
|
|
||||||
type PairingState =
|
type PairingState =
|
||||||
| { phase: "waiting" }
|
| { phase: "waiting" }
|
||||||
| { phase: "qr"; qrPng: string }
|
| { phase: "qr"; qrUrl: string }
|
||||||
| { phase: "connected"; phoneNumber: string }
|
| { phase: "connected"; phoneNumber: string }
|
||||||
| { phase: "timeout" };
|
| { phase: "timeout" };
|
||||||
|
|
||||||
@ -19,42 +19,31 @@ interface PairLiveProps {
|
|||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** SVG countdown ring. radius=54 so circumference ≈ 339.3 */
|
function CountdownBar({ seconds, total }: { seconds: number; total: number }) {
|
||||||
function CountdownRing({ seconds, total }: { seconds: number; total: number }) {
|
const pct = Math.max(0, Math.min(100, Math.round((seconds / total) * 100)));
|
||||||
const r = 54;
|
const danger = seconds <= 10;
|
||||||
const circ = 2 * Math.PI * r;
|
|
||||||
const progress = seconds / total;
|
|
||||||
const dash = circ * progress;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg
|
<div className="flex w-full max-w-64 flex-col gap-1">
|
||||||
className="absolute inset-0 -rotate-90"
|
<div className="flex items-center justify-between text-xs">
|
||||||
width="128"
|
<span className="text-muted-foreground">QR expires in</span>
|
||||||
height="128"
|
<span
|
||||||
viewBox="0 0 128 128"
|
className={`font-mono tabular-nums font-medium ${
|
||||||
aria-hidden
|
danger ? "text-destructive" : "text-foreground"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{/* Track */}
|
{seconds}s
|
||||||
<circle
|
</span>
|
||||||
cx="64"
|
</div>
|
||||||
cy="64"
|
<div className="h-1 w-full overflow-hidden rounded-full bg-muted">
|
||||||
r={r}
|
<div
|
||||||
fill="none"
|
className={`h-full transition-[width] duration-1000 ease-linear ${
|
||||||
strokeWidth="3"
|
danger ? "bg-destructive" : "bg-foreground/70"
|
||||||
className="stroke-muted"
|
}`}
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
{/* Remaining */}
|
</div>
|
||||||
<circle
|
</div>
|
||||||
cx="64"
|
|
||||||
cy="64"
|
|
||||||
r={r}
|
|
||||||
fill="none"
|
|
||||||
strokeWidth="3"
|
|
||||||
strokeDasharray={`${dash} ${circ}`}
|
|
||||||
strokeLinecap="round"
|
|
||||||
className="stroke-foreground transition-[stroke-dasharray] duration-1000 ease-linear"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -84,7 +73,8 @@ export function PairLive({ accountId, label }: PairLiveProps) {
|
|||||||
useEvents({
|
useEvents({
|
||||||
"session.qr": (data) => {
|
"session.qr": (data) => {
|
||||||
if (data.accountId !== accountId) return;
|
if (data.accountId !== accountId) return;
|
||||||
setPairingState({ phase: "qr", qrPng: data.qrPng });
|
// Bust the URL with the timestamp so the browser refetches each time.
|
||||||
|
setPairingState({ phase: "qr", qrUrl: `/api/qr/${accountId}?t=${data.ts}` });
|
||||||
startCountdown();
|
startCountdown();
|
||||||
},
|
},
|
||||||
"session.connected": (data) => {
|
"session.connected": (data) => {
|
||||||
@ -136,39 +126,33 @@ export function PairLive({ accountId, label }: PairLiveProps) {
|
|||||||
|
|
||||||
{pairingState.phase === "qr" && (
|
{pairingState.phase === "qr" && (
|
||||||
<div className="flex flex-col items-center gap-4">
|
<div className="flex flex-col items-center gap-4">
|
||||||
{/* QR + ring overlay */}
|
{/* Countdown — separate from the QR so it doesn't obstruct scanning */}
|
||||||
<div className="relative">
|
<CountdownBar seconds={countdown} total={COUNTDOWN_TOTAL} />
|
||||||
{/* Countdown ring positioned around the QR */}
|
|
||||||
<div className="absolute -inset-5 flex items-center justify-center pointer-events-none">
|
|
||||||
<div className="relative size-32">
|
|
||||||
<CountdownRing seconds={countdown} total={COUNTDOWN_TOTAL} />
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
|
||||||
<span
|
|
||||||
className={`font-mono text-xs tabular-nums font-medium transition-colors ${
|
|
||||||
countdown <= 10 ? "text-destructive" : "text-muted-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{countdown}s
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* QR image */}
|
{/* QR image */}
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src={`data:image/png;base64,${pairingState.qrPng}`}
|
src={pairingState.qrUrl}
|
||||||
alt="WhatsApp QR code"
|
alt="WhatsApp QR code"
|
||||||
width={256}
|
width={256}
|
||||||
height={256}
|
height={256}
|
||||||
className="rounded-lg ring-1 ring-foreground/10"
|
className="rounded-lg ring-1 ring-foreground/10"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col items-center gap-1 text-center">
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
<p className="text-sm font-medium">Scan with WhatsApp → Linked Devices</p>
|
<p className="text-sm font-medium">Scan with WhatsApp → Linked Devices</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Open WhatsApp → tap ⋮ → Linked Devices → Link a device
|
Open WhatsApp → tap ⋮ → Linked Devices → Link a device
|
||||||
</p>
|
</p>
|
||||||
|
<Button asChild variant="outline" size="sm" className="mt-1">
|
||||||
|
<a
|
||||||
|
href={pairingState.qrUrl}
|
||||||
|
download={`whatsapp-qr-${label.replace(/\s+/g, "-").toLowerCase() || accountId}.png`}
|
||||||
|
>
|
||||||
|
<DownloadIcon />
|
||||||
|
Save QR
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -19,6 +19,7 @@ interface PassThroughParams {
|
|||||||
mediaId?: string;
|
mediaId?: string;
|
||||||
caption?: string;
|
caption?: string;
|
||||||
scheduledAt?: string;
|
scheduledAt?: string;
|
||||||
|
rrule?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GroupsFormClientProps {
|
interface GroupsFormClientProps {
|
||||||
@ -70,6 +71,7 @@ export function GroupsFormClient({
|
|||||||
if (passThroughParams.mediaId) sp.set("mediaId", passThroughParams.mediaId);
|
if (passThroughParams.mediaId) sp.set("mediaId", passThroughParams.mediaId);
|
||||||
if (passThroughParams.caption) sp.set("caption", passThroughParams.caption);
|
if (passThroughParams.caption) sp.set("caption", passThroughParams.caption);
|
||||||
if (passThroughParams.scheduledAt) sp.set("scheduledAt", passThroughParams.scheduledAt);
|
if (passThroughParams.scheduledAt) sp.set("scheduledAt", passThroughParams.scheduledAt);
|
||||||
|
if (passThroughParams.rrule) sp.set("rrule", passThroughParams.rrule);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
router.push(`/reminders/new?${sp.toString()}` as any);
|
router.push(`/reminders/new?${sp.toString()}` as any);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,7 @@ interface ReviewSubmitClientProps {
|
|||||||
mediaId?: string;
|
mediaId?: string;
|
||||||
caption?: string;
|
caption?: string;
|
||||||
scheduledAt: string;
|
scheduledAt: string;
|
||||||
|
rrule?: string;
|
||||||
timezone: string;
|
timezone: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -24,6 +25,7 @@ export function ReviewSubmitClient({
|
|||||||
mediaId,
|
mediaId,
|
||||||
caption,
|
caption,
|
||||||
scheduledAt,
|
scheduledAt,
|
||||||
|
rrule,
|
||||||
timezone,
|
timezone,
|
||||||
}: ReviewSubmitClientProps) {
|
}: ReviewSubmitClientProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -42,6 +44,7 @@ export function ReviewSubmitClient({
|
|||||||
mediaId: mediaId ?? null,
|
mediaId: mediaId ?? null,
|
||||||
caption: caption ?? null,
|
caption: caption ?? null,
|
||||||
scheduledAtIso: scheduledAt,
|
scheduledAtIso: scheduledAt,
|
||||||
|
rrule: rrule ?? null,
|
||||||
timezone,
|
timezone,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,7 @@ interface StepGroupsParams {
|
|||||||
mediaId?: string;
|
mediaId?: string;
|
||||||
caption?: string;
|
caption?: string;
|
||||||
scheduledAt?: string;
|
scheduledAt?: string;
|
||||||
|
rrule?: string;
|
||||||
groupId?: string;
|
groupId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -22,7 +23,15 @@ interface StepGroupsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function StepGroups({ params }: StepGroupsProps) {
|
export async function StepGroups({ params }: StepGroupsProps) {
|
||||||
const { accountId, groupIds: groupIdsParam, groupId: singleGroupId, scheduledAt, text, mediaId } = params;
|
const {
|
||||||
|
accountId,
|
||||||
|
groupIds: groupIdsParam,
|
||||||
|
groupId: singleGroupId,
|
||||||
|
scheduledAt,
|
||||||
|
text,
|
||||||
|
mediaId,
|
||||||
|
rrule,
|
||||||
|
} = params;
|
||||||
|
|
||||||
if (!accountId || !scheduledAt || (!text && !mediaId)) {
|
if (!accountId || !scheduledAt || (!text && !mediaId)) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@ -64,6 +73,7 @@ export async function StepGroups({ params }: StepGroupsProps) {
|
|||||||
if (mediaId) backParams.set("mediaId", mediaId);
|
if (mediaId) backParams.set("mediaId", mediaId);
|
||||||
if (params.caption) backParams.set("caption", params.caption);
|
if (params.caption) backParams.set("caption", params.caption);
|
||||||
if (scheduledAt) backParams.set("scheduledAt", scheduledAt);
|
if (scheduledAt) backParams.set("scheduledAt", scheduledAt);
|
||||||
|
if (rrule) backParams.set("rrule", rrule);
|
||||||
const backHref = `/reminders/new?${backParams.toString()}`;
|
const backHref = `/reminders/new?${backParams.toString()}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -92,6 +102,7 @@ export async function StepGroups({ params }: StepGroupsProps) {
|
|||||||
mediaId: params.mediaId,
|
mediaId: params.mediaId,
|
||||||
caption: params.caption,
|
caption: params.caption,
|
||||||
scheduledAt: params.scheduledAt,
|
scheduledAt: params.scheduledAt,
|
||||||
|
rrule,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -106,6 +117,7 @@ interface PassThroughParams {
|
|||||||
mediaId?: string;
|
mediaId?: string;
|
||||||
caption?: string;
|
caption?: string;
|
||||||
scheduledAt?: string;
|
scheduledAt?: string;
|
||||||
|
rrule?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function StepGroupsForm({
|
function StepGroupsForm({
|
||||||
|
|||||||
@ -1,12 +1,21 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { ArrowLeftIcon, PencilIcon, CalendarIcon, UsersIcon, FileTextIcon, SmartphoneIcon } from "lucide-react";
|
import {
|
||||||
|
ArrowLeftIcon,
|
||||||
|
PencilIcon,
|
||||||
|
CalendarIcon,
|
||||||
|
UsersIcon,
|
||||||
|
FileTextIcon,
|
||||||
|
SmartphoneIcon,
|
||||||
|
RepeatIcon,
|
||||||
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { getSeededOperator } from "@/lib/operator";
|
import { getSeededOperator } from "@/lib/operator";
|
||||||
import { getAccount, listGroupsForAccount } from "@/lib/queries";
|
import { getAccount, listGroupsForAccount } from "@/lib/queries";
|
||||||
import { ReviewSubmitClient } from "./review-submit-client";
|
import { ReviewSubmitClient } from "./review-submit-client";
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
|
import { describeRecurrence, kindFromRrule } from "@/lib/recurrence";
|
||||||
|
|
||||||
interface StepReviewParams {
|
interface StepReviewParams {
|
||||||
step?: string;
|
step?: string;
|
||||||
@ -16,12 +25,26 @@ interface StepReviewParams {
|
|||||||
mediaId?: string;
|
mediaId?: string;
|
||||||
caption?: string;
|
caption?: string;
|
||||||
scheduledAt?: string;
|
scheduledAt?: string;
|
||||||
|
rrule?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StepReviewProps {
|
interface StepReviewProps {
|
||||||
params: StepReviewParams;
|
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 {
|
function formatScheduledAt(iso: string, timezone: string): string {
|
||||||
try {
|
try {
|
||||||
const dt = DateTime.fromISO(iso, { zone: timezone });
|
const dt = DateTime.fromISO(iso, { zone: timezone });
|
||||||
@ -39,7 +62,8 @@ function editLink(
|
|||||||
text?: string,
|
text?: string,
|
||||||
mediaId?: string,
|
mediaId?: string,
|
||||||
caption?: string,
|
caption?: string,
|
||||||
scheduledAt?: string
|
scheduledAt?: string,
|
||||||
|
rrule?: string,
|
||||||
): string {
|
): string {
|
||||||
const sp = new URLSearchParams({ step: String(step), accountId });
|
const sp = new URLSearchParams({ step: String(step), accountId });
|
||||||
if (groupIds) sp.set("groupIds", groupIds);
|
if (groupIds) sp.set("groupIds", groupIds);
|
||||||
@ -47,11 +71,12 @@ function editLink(
|
|||||||
if (mediaId) sp.set("mediaId", mediaId);
|
if (mediaId) sp.set("mediaId", mediaId);
|
||||||
if (caption) sp.set("caption", caption);
|
if (caption) sp.set("caption", caption);
|
||||||
if (scheduledAt) sp.set("scheduledAt", scheduledAt);
|
if (scheduledAt) sp.set("scheduledAt", scheduledAt);
|
||||||
|
if (rrule) sp.set("rrule", rrule);
|
||||||
return `/reminders/new?${sp.toString()}`;
|
return `/reminders/new?${sp.toString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function StepReview({ params }: StepReviewProps) {
|
export async function StepReview({ params }: StepReviewProps) {
|
||||||
const { accountId, groupIds, text, mediaId, caption, scheduledAt } = params;
|
const { accountId, groupIds, text, mediaId, caption, scheduledAt, rrule } = params;
|
||||||
|
|
||||||
if (!accountId || !scheduledAt || (!text && !mediaId)) {
|
if (!accountId || !scheduledAt || (!text && !mediaId)) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@ -79,7 +104,7 @@ export async function StepReview({ params }: StepReviewProps) {
|
|||||||
const formattedDate = formatScheduledAt(scheduledAt, timezone);
|
const formattedDate = formatScheduledAt(scheduledAt, timezone);
|
||||||
|
|
||||||
// Back goes to step 4 (Groups, the previous step in the new order)
|
// Back goes to step 4 (Groups, the previous step in the new order)
|
||||||
const backHref = editLink(4, accountId, groupIds, text, mediaId, caption, scheduledAt);
|
const backHref = editLink(4, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
@ -102,7 +127,7 @@ export async function StepReview({ params }: StepReviewProps) {
|
|||||||
<ReviewRow
|
<ReviewRow
|
||||||
icon={<SmartphoneIcon className="size-4" />}
|
icon={<SmartphoneIcon className="size-4" />}
|
||||||
label="Account"
|
label="Account"
|
||||||
editHref={editLink(1, accountId, groupIds, text, mediaId, caption, scheduledAt)}
|
editHref={editLink(1, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule)}
|
||||||
>
|
>
|
||||||
<span className="text-sm font-medium">{account.label}</span>
|
<span className="text-sm font-medium">{account.label}</span>
|
||||||
{account.phoneNumber && (
|
{account.phoneNumber && (
|
||||||
@ -114,7 +139,7 @@ export async function StepReview({ params }: StepReviewProps) {
|
|||||||
<ReviewRow
|
<ReviewRow
|
||||||
icon={<FileTextIcon className="size-4" />}
|
icon={<FileTextIcon className="size-4" />}
|
||||||
label="Message"
|
label="Message"
|
||||||
editHref={editLink(2, accountId, groupIds, text, mediaId, caption, scheduledAt)}
|
editHref={editLink(2, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule)}
|
||||||
>
|
>
|
||||||
{mediaId ? (
|
{mediaId ? (
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
@ -136,17 +161,34 @@ export async function StepReview({ params }: StepReviewProps) {
|
|||||||
{/* When */}
|
{/* When */}
|
||||||
<ReviewRow
|
<ReviewRow
|
||||||
icon={<CalendarIcon className="size-4" />}
|
icon={<CalendarIcon className="size-4" />}
|
||||||
label="When"
|
label={rrule ? "First fire" : "When"}
|
||||||
editHref={editLink(3, accountId, groupIds, text, mediaId, caption, scheduledAt)}
|
editHref={editLink(3, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule)}
|
||||||
>
|
>
|
||||||
<span className="text-sm font-medium">{formattedDate}</span>
|
<span className="text-sm font-medium">{formattedDate}</span>
|
||||||
</ReviewRow>
|
</ReviewRow>
|
||||||
|
|
||||||
|
{/* Recurrence (only if set) */}
|
||||||
|
{rrule && (
|
||||||
|
<ReviewRow
|
||||||
|
icon={<RepeatIcon className="size-4" />}
|
||||||
|
label="Repeats"
|
||||||
|
editHref={editLink(3, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule)}
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{describeRecurrence(
|
||||||
|
kindFromRrule(rrule),
|
||||||
|
DateTime.fromISO(scheduledAt!, { zone: timezone }),
|
||||||
|
parseWeeklyDaysFromRrule(rrule),
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</ReviewRow>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Groups */}
|
{/* Groups */}
|
||||||
<ReviewRow
|
<ReviewRow
|
||||||
icon={<UsersIcon className="size-4" />}
|
icon={<UsersIcon className="size-4" />}
|
||||||
label="Groups"
|
label="Groups"
|
||||||
editHref={editLink(4, accountId, groupIds, text, mediaId, caption, scheduledAt)}
|
editHref={editLink(4, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule)}
|
||||||
>
|
>
|
||||||
{selectedGroups.length > 0 ? (
|
{selectedGroups.length > 0 ? (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
@ -174,6 +216,7 @@ export async function StepReview({ params }: StepReviewProps) {
|
|||||||
mediaId={mediaId}
|
mediaId={mediaId}
|
||||||
caption={caption}
|
caption={caption}
|
||||||
scheduledAt={scheduledAt}
|
scheduledAt={scheduledAt}
|
||||||
|
rrule={rrule}
|
||||||
timezone={timezone}
|
timezone={timezone}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { DateTime } from "luxon";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { getSeededOperator } from "@/lib/operator";
|
import { getSeededOperator } from "@/lib/operator";
|
||||||
import { WhenFormClient } from "./when-form-client";
|
import { WhenFormClient } from "./when-form-client";
|
||||||
|
import { kindFromRrule } from "@/lib/recurrence";
|
||||||
|
|
||||||
interface StepWhenParams {
|
interface StepWhenParams {
|
||||||
step?: string;
|
step?: string;
|
||||||
@ -14,14 +15,29 @@ interface StepWhenParams {
|
|||||||
mediaId?: string;
|
mediaId?: string;
|
||||||
caption?: string;
|
caption?: string;
|
||||||
scheduledAt?: string;
|
scheduledAt?: string;
|
||||||
|
rrule?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StepWhenProps {
|
interface StepWhenProps {
|
||||||
params: StepWhenParams;
|
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) {
|
export async function StepWhen({ params }: StepWhenProps) {
|
||||||
const { accountId, groupIds, text, mediaId, caption, scheduledAt } = params;
|
const { accountId, groupIds, text, mediaId, caption, scheduledAt, rrule } = params;
|
||||||
|
|
||||||
if (!accountId || (!text && !mediaId)) {
|
if (!accountId || (!text && !mediaId)) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@ -36,6 +52,7 @@ export async function StepWhen({ params }: StepWhenProps) {
|
|||||||
if (text) backParams.set("text", text);
|
if (text) backParams.set("text", text);
|
||||||
if (mediaId) backParams.set("mediaId", mediaId);
|
if (mediaId) backParams.set("mediaId", mediaId);
|
||||||
if (caption) backParams.set("caption", caption);
|
if (caption) backParams.set("caption", caption);
|
||||||
|
if (rrule) backParams.set("rrule", rrule);
|
||||||
const backHref = `/reminders/new?${backParams.toString()}`;
|
const backHref = `/reminders/new?${backParams.toString()}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -63,6 +80,8 @@ export async function StepWhen({ params }: StepWhenProps) {
|
|||||||
scheduledAt ??
|
scheduledAt ??
|
||||||
DateTime.now().setZone(timezone).plus({ hours: 1 }).startOf("minute").toISO()!
|
DateTime.now().setZone(timezone).plus({ hours: 1 }).startOf("minute").toISO()!
|
||||||
}
|
}
|
||||||
|
initialKind={kindFromRrule(rrule)}
|
||||||
|
initialWeeklyDays={parseWeeklyDays(rrule)}
|
||||||
passThroughParams={{ text, mediaId, caption }}
|
passThroughParams={{ text, mediaId, caption }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,10 +3,16 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
import { CalendarIcon, ClockIcon, AlertCircleIcon } from "lucide-react";
|
import { CalendarIcon, ClockIcon, AlertCircleIcon, RepeatIcon } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
WEEKDAY_LABELS,
|
||||||
|
buildRrule,
|
||||||
|
type RecurrenceKind,
|
||||||
|
} from "@/lib/recurrence";
|
||||||
|
|
||||||
interface PassThroughParams {
|
interface PassThroughParams {
|
||||||
text?: string;
|
text?: string;
|
||||||
@ -18,11 +24,20 @@ interface WhenFormClientProps {
|
|||||||
accountId: string;
|
accountId: string;
|
||||||
groupIds: string;
|
groupIds: string;
|
||||||
timezone: string;
|
timezone: string;
|
||||||
/** Pre-computed default ISO from the server — guarantees no hydration drift. */
|
|
||||||
initialDefaultIso: string;
|
initialDefaultIso: string;
|
||||||
|
initialKind?: RecurrenceKind;
|
||||||
|
initialWeeklyDays?: number[];
|
||||||
passThroughParams: PassThroughParams;
|
passThroughParams: PassThroughParams;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const KINDS: Array<{ value: RecurrenceKind; label: string }> = [
|
||||||
|
{ value: "none", label: "One-off" },
|
||||||
|
{ value: "daily", label: "Daily" },
|
||||||
|
{ value: "weekly", label: "Weekly" },
|
||||||
|
{ value: "monthly", label: "Monthly" },
|
||||||
|
{ value: "yearly", label: "Yearly" },
|
||||||
|
];
|
||||||
|
|
||||||
function splitDateTime(iso: string, tz: string): { date: string; time: string } {
|
function splitDateTime(iso: string, tz: string): { date: string; time: string } {
|
||||||
const dt = DateTime.fromISO(iso, { zone: tz });
|
const dt = DateTime.fromISO(iso, { zone: tz });
|
||||||
if (!dt.isValid) return { date: "", time: "" };
|
if (!dt.isValid) return { date: "", time: "" };
|
||||||
@ -34,6 +49,8 @@ export function WhenFormClient({
|
|||||||
groupIds,
|
groupIds,
|
||||||
timezone,
|
timezone,
|
||||||
initialDefaultIso,
|
initialDefaultIso,
|
||||||
|
initialKind = "none",
|
||||||
|
initialWeeklyDays = [],
|
||||||
passThroughParams,
|
passThroughParams,
|
||||||
}: WhenFormClientProps) {
|
}: WhenFormClientProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -41,8 +58,16 @@ export function WhenFormClient({
|
|||||||
|
|
||||||
const [date, setDate] = useState(initial.date);
|
const [date, setDate] = useState(initial.date);
|
||||||
const [time, setTime] = useState(initial.time);
|
const [time, setTime] = useState(initial.time);
|
||||||
|
const [kind, setKind] = useState<RecurrenceKind>(initialKind);
|
||||||
|
const [weeklyDays, setWeeklyDays] = useState<number[]>(initialWeeklyDays);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function toggleWeekday(iso: number) {
|
||||||
|
setWeeklyDays((prev) =>
|
||||||
|
prev.includes(iso) ? prev.filter((d) => d !== iso) : [...prev, iso].sort((a, b) => a - b),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function handleContinue() {
|
function handleContinue() {
|
||||||
if (!date || !time) {
|
if (!date || !time) {
|
||||||
setError("Pick both a date and a time.");
|
setError("Pick both a date and a time.");
|
||||||
@ -54,9 +79,10 @@ export function WhenFormClient({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (dt.toMillis() <= Date.now()) {
|
if (dt.toMillis() <= Date.now()) {
|
||||||
setError("The selected time is in the past. Choose a future time.");
|
setError("The first occurrence is in the past. Pick a future date and time.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const rrule = buildRrule(kind, dt, weeklyDays);
|
||||||
const scheduledAt = dt.toISO()!;
|
const scheduledAt = dt.toISO()!;
|
||||||
const sp = new URLSearchParams({
|
const sp = new URLSearchParams({
|
||||||
step: "4",
|
step: "4",
|
||||||
@ -64,6 +90,7 @@ export function WhenFormClient({
|
|||||||
scheduledAt,
|
scheduledAt,
|
||||||
});
|
});
|
||||||
if (groupIds) sp.set("groupIds", groupIds);
|
if (groupIds) sp.set("groupIds", groupIds);
|
||||||
|
if (rrule) sp.set("rrule", rrule);
|
||||||
if (passThroughParams.text) sp.set("text", passThroughParams.text);
|
if (passThroughParams.text) sp.set("text", passThroughParams.text);
|
||||||
if (passThroughParams.mediaId) sp.set("mediaId", passThroughParams.mediaId);
|
if (passThroughParams.mediaId) sp.set("mediaId", passThroughParams.mediaId);
|
||||||
if (passThroughParams.caption) sp.set("caption", passThroughParams.caption);
|
if (passThroughParams.caption) sp.set("caption", passThroughParams.caption);
|
||||||
@ -73,11 +100,12 @@ export function WhenFormClient({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
|
{/* Date + time */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="scheduled-date" className="flex items-center gap-1.5">
|
<Label htmlFor="scheduled-date" className="flex items-center gap-1.5">
|
||||||
<CalendarIcon className="size-3.5" />
|
<CalendarIcon className="size-3.5" />
|
||||||
Date
|
{kind === "none" ? "Date" : "Starts on"}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="scheduled-date"
|
id="scheduled-date"
|
||||||
@ -108,6 +136,66 @@ export function WhenFormClient({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Recurrence */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="flex items-center gap-1.5">
|
||||||
|
<RepeatIcon className="size-3.5" />
|
||||||
|
Repeats
|
||||||
|
</Label>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{KINDS.map(({ value, label }) => {
|
||||||
|
const active = kind === value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setKind(value)}
|
||||||
|
aria-pressed={active}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium transition-colors",
|
||||||
|
active
|
||||||
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
|
: "border-border bg-muted/50 hover:border-primary/50 hover:bg-primary/5 hover:text-primary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Weekday picker — only for weekly */}
|
||||||
|
{kind === "weekly" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Days of the week</Label>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{WEEKDAY_LABELS.map(({ iso, short }) => {
|
||||||
|
const active = weeklyDays.includes(iso);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={iso}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleWeekday(iso)}
|
||||||
|
aria-pressed={active}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-8 min-w-12 items-center justify-center rounded-lg border px-2.5 text-xs font-medium transition-colors",
|
||||||
|
active
|
||||||
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
|
: "border-border bg-muted/50 hover:border-primary/50 hover:bg-primary/5 hover:text-primary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{short}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Leave empty to use the start date's weekday only.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="flex items-center gap-1.5 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
<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" />
|
<AlertCircleIcon className="size-3.5 shrink-0" />
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { useEffect } from "react";
|
|||||||
export type WebEventMap = {
|
export type WebEventMap = {
|
||||||
hello: { ts: number };
|
hello: { ts: number };
|
||||||
ping: { ts: number };
|
ping: { ts: number };
|
||||||
"session.qr": { accountId: string; qrPng: string };
|
"session.qr": { accountId: string; ts: number };
|
||||||
"session.connected": { accountId: string; phoneNumber: string | null };
|
"session.connected": { accountId: string; phoneNumber: string | null };
|
||||||
"session.disconnected": { accountId: string };
|
"session.disconnected": { accountId: string };
|
||||||
"session.timeout": { accountId: string };
|
"session.timeout": { accountId: string };
|
||||||
|
|||||||
67
apps/web/src/lib/recurrence.test.ts
Normal file
67
apps/web/src/lib/recurrence.test.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { DateTime } from "luxon";
|
||||||
|
import {
|
||||||
|
buildRrule,
|
||||||
|
describeRecurrence,
|
||||||
|
kindFromRrule,
|
||||||
|
} from "./recurrence";
|
||||||
|
|
||||||
|
const FIRST = DateTime.fromISO("2026-05-13T09:00:00", { zone: "Asia/Kuala_Lumpur" });
|
||||||
|
|
||||||
|
describe("buildRrule", () => {
|
||||||
|
it("returns null for one-off", () => {
|
||||||
|
expect(buildRrule("none", FIRST, [])).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("daily → FREQ=DAILY", () => {
|
||||||
|
expect(buildRrule("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("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("monthly uses BYMONTHDAY of the first-fire date", () => {
|
||||||
|
expect(buildRrule("monthly", FIRST, [])).toBe("FREQ=MONTHLY;BYMONTHDAY=13");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("yearly uses BYMONTH and BYMONTHDAY", () => {
|
||||||
|
expect(buildRrule("yearly", FIRST, [])).toBe("FREQ=YEARLY;BYMONTH=5;BYMONTHDAY=13");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("kindFromRrule", () => {
|
||||||
|
it("recognises every supported FREQ", () => {
|
||||||
|
expect(kindFromRrule(null)).toBe("none");
|
||||||
|
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("returns 'none' for an unrecognised rule", () => {
|
||||||
|
expect(kindFromRrule("FREQ=HOURLY")).toBe("none");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
91
apps/web/src/lib/recurrence.ts
Normal file
91
apps/web/src/lib/recurrence.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import { DateTime } from "luxon";
|
||||||
|
|
||||||
|
export type RecurrenceKind = "none" | "daily" | "weekly" | "monthly" | "yearly";
|
||||||
|
|
||||||
|
/** 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 }> = [
|
||||||
|
{ iso: 1, code: "MO", short: "Mon", long: "Monday" },
|
||||||
|
{ iso: 2, code: "TU", short: "Tue", long: "Tuesday" },
|
||||||
|
{ iso: 3, code: "WE", short: "Wed", long: "Wednesday" },
|
||||||
|
{ iso: 4, code: "TH", short: "Thu", long: "Thursday" },
|
||||||
|
{ iso: 5, code: "FR", short: "Fri", long: "Friday" },
|
||||||
|
{ iso: 6, code: "SA", short: "Sat", long: "Saturday" },
|
||||||
|
{ iso: 7, code: "SU", short: "Sun", long: "Sunday" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export function buildRrule(
|
||||||
|
kind: RecurrenceKind,
|
||||||
|
firstFire: DateTime,
|
||||||
|
weeklyDays: number[],
|
||||||
|
): string | null {
|
||||||
|
switch (kind) {
|
||||||
|
case "none":
|
||||||
|
return null;
|
||||||
|
case "daily":
|
||||||
|
return "FREQ=DAILY";
|
||||||
|
case "weekly": {
|
||||||
|
const days =
|
||||||
|
weeklyDays.length > 0
|
||||||
|
? 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(",")}`;
|
||||||
|
}
|
||||||
|
case "monthly":
|
||||||
|
return `FREQ=MONTHLY;BYMONTHDAY=${firstFire.day}`;
|
||||||
|
case "yearly":
|
||||||
|
return `FREQ=YEARLY;BYMONTH=${firstFire.month};BYMONTHDAY=${firstFire.day}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 labels = days
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a - b)
|
||||||
|
.map((d) => WEEKDAY_LABELS[d - 1]?.short)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ");
|
||||||
|
return `Every ${labels}`;
|
||||||
|
}
|
||||||
|
case "monthly":
|
||||||
|
return `Every month on day ${firstFire.day}`;
|
||||||
|
case "yearly":
|
||||||
|
return `Every year on ${firstFire.toFormat("MMM d")}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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";
|
||||||
|
}
|
||||||
@ -3,9 +3,13 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
export function middleware(req: NextRequest) {
|
export function middleware(req: NextRequest) {
|
||||||
const path = req.nextUrl.pathname;
|
const path = req.nextUrl.pathname;
|
||||||
|
|
||||||
// Block all /api/* except the read-only SSE and health endpoints.
|
// Block all /api/* except a small set of read-only endpoints.
|
||||||
// Mutations happen via Server Actions which post to page URLs, not /api/*.
|
// Mutations happen via Server Actions which post to page URLs, not /api/*.
|
||||||
if (path.startsWith("/api/") && path !== "/api/events" && path !== "/api/health") {
|
const allowed =
|
||||||
|
path === "/api/events" ||
|
||||||
|
path === "/api/health" ||
|
||||||
|
path.startsWith("/api/qr/");
|
||||||
|
if (path.startsWith("/api/") && !allowed) {
|
||||||
return new NextResponse("Not Found", { status: 404 });
|
return new NextResponse("Not Found", { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
2
packages/db/migrations/0004_next_prowler.sql
Normal file
2
packages/db/migrations/0004_next_prowler.sql
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "reminders" ADD COLUMN "last_fired_at" timestamp with time zone;--> statement-breakpoint
|
||||||
|
ALTER TABLE "whatsapp_accounts" ADD COLUMN "last_qr_png" text;
|
||||||
1013
packages/db/migrations/meta/0004_snapshot.json
Normal file
1013
packages/db/migrations/meta/0004_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -29,6 +29,13 @@
|
|||||||
"when": 1778343712901,
|
"when": 1778343712901,
|
||||||
"tag": "0003_messy_bruce_banner",
|
"tag": "0003_messy_bruce_banner",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1778345543406,
|
||||||
|
"tag": "0004_next_prowler",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -37,6 +37,7 @@ export const whatsappAccounts = pgTable(
|
|||||||
status: text("status").notNull().default("pending"),
|
status: text("status").notNull().default("pending"),
|
||||||
lastConnectedAt: timestamp("last_connected_at", { withTimezone: true }),
|
lastConnectedAt: timestamp("last_connected_at", { withTimezone: true }),
|
||||||
lastQrAt: timestamp("last_qr_at", { withTimezone: true }),
|
lastQrAt: timestamp("last_qr_at", { withTimezone: true }),
|
||||||
|
lastQrPng: text("last_qr_png"),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
},
|
},
|
||||||
(t) => ({
|
(t) => ({
|
||||||
@ -85,6 +86,7 @@ export const reminders = pgTable("reminders", {
|
|||||||
createdBy: uuid("created_by").notNull().references(() => operators.id),
|
createdBy: uuid("created_by").notNull().references(() => operators.id),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
lastFiredAt: timestamp("last_fired_at", { withTimezone: true }),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const reminderTargets = pgTable(
|
export const reminderTargets = pgTable(
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user