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>
116 lines
3.7 KiB
TypeScript
116 lines
3.7 KiB
TypeScript
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");
|
|
});
|
|
});
|