Previously the name field auto-derived from the first text part when
the operator left it blank. That's brittle once reminders carry
multiple parts of varying provenance, and confusing in lists where
"Reminder" or partial sentences crowd in.
Now: every reminder must carry a non-empty name, capped at 60 chars.
- Zod schema on createReminder/updateReminder: name moves from
`z.string().nullable().optional()` to
`z.string().trim().min(1, "Give the reminder a name").max(60)`.
Stale-URL legacy callers that omit it now get a clear server error.
- Wizard compose step: input has `required` + `aria-required`,
placeholder + label simplified ("(optional)" tag and the helper
paragraph removed), Continue blocks on empty.
- Edit-message form: same — required, aria-required, save blocked
on empty, the "leave blank and we'll auto-derive" hint dropped.
- Review-submit client: defensive fail-fast for stale-bookmark URLs
that arrive at step 5 without a name — bounces back with
"Give the reminder a name (back on the Message step)" instead of
letting the server reject.
The resolveReminderName helper stays put — duplicateReminderAction
and any future caller still benefit from the trim+clamp+fallback
chain. Helper unit tests unaffected (they test the resolver in
isolation, the policy-tightening lives at the schema layer above).
298 web tests still passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reminders pick up a real, user-controlled name instead of being
auto-named from the first message body. Auto-derive stays as the
fallback so empty inputs still produce something useful.
Resolution policy (single source of truth in lib/reminder-name.ts)
------------------------------------------------------------------
1. User-supplied name, trimmed, clamped to 60 chars.
2. First text-bearing message part — text body or media caption,
trimmed, clamped to 60.
3. Literal "Reminder" (only if every part is media-without-caption
and no name was given).
Wizard
------
- New "Name" input above the message stack on step 2 (Compose).
Optional (label says so), maxLength 60, placeholder gives an
example. Blank flows through the URL as an absent param.
- The name parameter passes through every subsequent step
(when, groups, review) via the existing URL-state pattern.
- Review step gains a "Name" row at the very top showing what the
resolver will produce. If the user left it blank, the row shows
the auto-derived value plus a muted "(auto from message)" tag so
they know what's happening.
Edit forms
----------
- `EditMessageForm` gains the same Name input at the top —
consistent with the wizard's compose step.
- `EditAccountForm` / `EditWhenForm` / `EditGroupsForm` accept the
current `name` and forward it unchanged on save. Otherwise saving
any of those sections would re-auto-derive the name from the
message body, silently overriding what the operator typed.
Server action
-------------
- Both `createReminderAction` and `updateReminderAction` accept an
optional `name` field on the schema. The body collapses through
the new `resolveReminderName` helper, replacing the inline
`firstLabel ?? "Reminder"` slice.
Tests (+17 new in lib/reminder-name.test.ts)
--------------------------------------------
- User priority: user name wins over message body even when both
are present; trimming.
- Auto-derive: first text part, first non-empty after skipping
empties, media caption when present, trims around the value.
- Fallback: null/undefined/empty stack, every-part-empty, every
part media-without-caption.
- Clamping: user-supplied long names truncate at 60; auto-derived
long names truncate at 60; short names pass through.
- The 60-char ceiling matches what the wizard's <Input maxLength>
enforces and what the DB column allows.
Existing tests updated to pass the new required prop (`initialName`
on EditMessageForm, `name` on EditAccountForm/EditGroupsForm SSR
fixtures, plus a couple in no-render-warnings.test.tsx).
Total: 298 web + 31 shared + 26 bot = 355 passing (was 338).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reminders can now deliver a stack of message parts in send order. The
DB and bot's fire-reminder loop already supported this — only the UI
and the server action's input shape were single-message. This change
makes the whole flow stack-aware end-to-end.
What's new
----------
A reminder is now a list of MessagePart objects:
{ kind: "text", textContent: "Hi", mediaId: null }
{ kind: "media", textContent: "cap", mediaId: uuid }
{ kind: "media", textContent: null, mediaId: uuid }
The bot fires them in order with ~1.5 s spacing (already the case in
fire-reminder.ts).
Cap of 10 parts per reminder. Anything more clutters the URL beyond
the 2KB practical budget for the wizard's encoded `messages=…` param.
Where this shows up
-------------------
1. `<MessageStack>` — new shared component (apps/web/src/components/
message-stack.tsx). Each block is either a text Textarea or a
media block (file picker + preview + caption Input). Per-block
move-up / move-down / delete buttons. "+ Add text" / "+ Add file"
buttons at the bottom. Reused by both the wizard's compose step
AND the per-section Edit Message page.
2. Edit Message page — was a single Textarea + read-only attachment
indicator with a "Replacing it isn't supported" note. Now uses
MessageStack and lets the operator add/remove/reorder parts AND
swap the file on a media block, fixing
the asked-for "should let user change media files too" gap.
3. Wizard — Compose / When / Groups / Review pass a single
`messages=<urlencoded JSON>` param instead of three separate
text/mediaId/caption fields. The Review step renders one row per
part, with file names resolved from the DB so users see "menu.pdf"
not an opaque uuid. Every step accepts the legacy fields too and
folds them into the new shape on entry, so older bookmarked URLs
keep working.
4. Server actions (createReminder / updateReminder) accept either:
- The new `messages: MessagePart[]` field, OR
- The legacy `text` / `mediaId` / `caption` triple,
and resolve to a flat parts list before doing anything else. Both
actions then write one row per part into `reminder_messages` with
a sequential `position` column, replacing the old "always 1 row"
logic in updateReminderAction.
5. The reminder name (visible in lists, detail header, etc.) is
sourced from the first part with a non-empty text body — falling
back to the literal "Reminder" if every part is media-without-
caption. Capped at 50 chars to fit the existing column.
Wire-format helpers
-------------------
New `lib/reminder-messages.ts`:
- `MessagePart` interface (the canonical shape)
- `isValidMessagePart` — reject empty texts and orphan-mediaId rows
- `encodeMessages` / `decodeMessages` — URI-encoded JSON, drops
invalid entries, returns null when nothing valid is left
- `legacyMessageToParts` — synthesise a one-element stack from the
old text/mediaId/caption fields (used by step pages on entry)
Tests (15 + 5 = 20 new; 146 total, was 132 + adjustment)
--------------------------------------------------------
- `lib/reminder-messages.test.ts`: round-trip a non-trivial stack;
survive URL-unsafe characters in text (\\n, & = % #); reject
null / empty / garbage; drop invalid entries; legacy-fallback paths.
- `edit-message-form.test.tsx`: rewrites for the new prop shape
(initialMessages instead of initialText/initialMediaId/initialCaption);
asserts the form renders one block per initial part and that media
filename appears in the SSR markup.
- `no-render-warnings.test.tsx`: same prop-shape update for the two
EditMessageForm hydration / button-nesting guards.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each schedule row in the recurrence picker now owns its own HH:MM
fire time. Previously every row inherited the time from the date+time
inputs above, which meant you couldn't say "every Monday at 09:00 AND
every Friday at 17:00" — both rules shared whatever the form-level
time was.
The Daily / Weekly / Monthly / Yearly tabs each render a small
"Fires at" time field. The picker still seeds new rules with the
form's date+time so the most common case (one rule, time matches the
first fire) doesn't need extra clicks.
Round-trip: the cron line `35 17 * * 1` now restores hour=17, minute=35
on a weekly draft. The parser pulls MM HH off the front of every
expression and feeds the rest of the pattern matchers as before.
Also clean up two pieces of duplicated/obsolete UI feedback in both
the wizard When step and the edit When form:
- Removed the `<p>` showing "Cron: 0 9 * * *" — the per-row
description ("Every day at 09:00") already says it, in human form.
- Removed the standalone "Times are in Asia/Kuala_Lumpur" footer.
The timezone is shown elsewhere (header) and the cron output is
always evaluated in the configured zone — telling the user "times
are in <tz>" inline in the picker is noise.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The custom RRULE panel covers most patterns but can't express "every
weekday at 9, 12, and 18" or "every 15 minutes within working hours"
in a single rule. RRULE's BYxxx fields can technically combine, but
the picker UX gets unwieldy fast. Cron expressions cover everything
in one line.
Storage / dispatch
- Cron rules live in the same `reminders.rrule` column with a sentinel
prefix: `CRON:0 9 * * 1-5`. No schema change.
- `@cmbot/shared` now exports:
CRON_PREFIX, isCronRule, stripCronPrefix, validateCronExpression
- `nextOccurrence(rule, tz, after)` and `validateMinInterval(rule, tz)`
detect the prefix and dispatch to `cron-parser`; non-cron rules
continue to flow through rrule unchanged.
- `cron-parser@^5.5.0` added as an explicit dep on @cmbot/shared
(it was already transitively present via pg-boss).
Server actions
- `createReminderAction` / `updateReminderAction`: when rrule has the
CRON: prefix, the user's date+time inputs are ignored — the action
validates the cron, runs the min-interval check (5 min between
fires), and computes scheduledAt as the next match of the cron
expression after now. The bot's existing fire-reminder loop
re-arms via `nextOccurrence` after each fire, which already speaks
cron via the dispatch above.
Picker
- New "Cron expression…" preset at the bottom of the radio list:
"Full sec/min/hour/day/month/dow combinational power"
Selecting it reveals a CronPanel:
* font-mono cron input (5- or 6-field accepted)
* inline examples: 0 9 * * 1-5, */15 * * * *, 0 9,12,18 * * *,
0 0 1 * *
* note that the Date+Time controls above are ignored once a cron
expression is set
- RecurrenceSpec gains an optional `cron` string and a new `kind: "cron"`.
- buildRrule emits `CRON:<expr>` for cron specs.
- specFromRrule round-trips a CRON-prefixed rule back into the spec.
- describeRecurrence renders "Cron: <expr>" so the list view and
review steps show the expression.
Tests (+10; 17 shared + 26 bot + 138 web = 181 total)
- packages/shared rrule.test.ts (+8):
* CRON_PREFIX / isCronRule / stripCronPrefix
* nextOccurrence on a CRON rule returns the right next match in the
operator timezone (e.g. weekday 9 AM KL ↔ exact UTC instant)
* RRULE rules still flow through unchanged
* validateMinInterval on cron: hourly OK, every-minute rejected,
malformed string returns a useful error
* validateCronExpression positive + negative cases
- recurrence.test.ts (+5): cron preset round-trip, label assertions,
`buildRrule`/`specFromRrule` for cron specs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The old picker was a row of 5 frequency pills (One-off / Daily / Weekly /
Monthly / Yearly) followed by a separate detail panel — common cases
needed several clicks (interval, weekday list, etc.) and the visual
hierarchy didn't show what was selected at a glance.
New design — a vertical radio list with seven first-fire-aware presets
plus a Custom… expander:
○ Don't repeat (one-off)
○ Every day
○ Every weekday (Mon – Fri)
○ Every weekend (Sat – Sun)
○ Every week on Wed (matches start)
○ Every month on day 13 (matches start)
○ Every year on May 13 (matches start)
○ Custom… ▼ expands
Custom… reveals the existing power-user controls (frequency dropdown,
interval input, weekday picker, day-of-month, end-condition) without
crowding the common path. Toggling between presets and custom is
lossless — the spec is the source of truth.
New helpers in `lib/recurrence.ts`:
- `presetToSpec(id, firstFire)` — canonical RecurrenceSpec for each
preset (round-trippable).
- `matchPreset(spec, firstFire)` — reverse mapping; returns "custom"
for anything that doesn't fit a shortcut, so the picker auto-flips
into expanded mode for non-preset specs.
- `presetDescriptors(firstFire)` — list of preset id/label/hint with
first-fire-aware copy ("Every week on Wed", "May 13", etc).
Wired into both:
- reminder-wizard/when-form-client.tsx (creating)
- reminder-edit/edit-when-form.tsx (editing a section in place)
Tests (+4, 134 web + 26 bot = 160 total green):
- recurrence.test.ts gains a "preset shortcuts" suite covering:
* presetToSpec → canonical spec for each id
* round-trip via matchPreset
* matchPreset returns "custom" for non-shortcut specs
(interval > 1, weekly Mon/Wed/Fri, end=after, monthly on a
different day-of-month than the first fire)
* presetDescriptors labels are first-fire-aware
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
- Fix "Invalid datetime" error: createReminderAction's Zod schema rejected
offset-suffixed ISO strings (luxon's `toISO()` produces +08:00 form).
Switched to `.datetime({ offset: true })`.
- Replace the single datetime-local input with separate native date + time
inputs (proper UI pickers on both desktop and mobile). Default value is
now computed server-side ("now + 1h") and passed in as a prop, so first
render is fully populated and there's no SSR/client hydration mismatch
from `Date.now()` inside the client component. Removed the quick-pick
shortcuts.
- Reorder wizard steps: Account → Compose → When → Groups → Review.
Groups is now the last and optional step (Continue button reads
"Skip groups" when empty); the action accepts an empty array and
inserts no reminder_targets in that case.
- Account list: card is the link target. Removed inline Pair / Open /
Delete quick-action buttons; lifecycle actions stay on the detail page.
- Account detail: removed the "Sync Groups Now" card. The bot already
auto-syncs on `groups.upsert` / `groups.update` events. The Groups card
itself is now a clickable link instead of carrying an inline View
button.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reshape the account lifecycle to match how operators actually want to
work the system:
- Add Account → creates a row with status='unpaired'. No QR yet; the
operator lands on the detail page.
- Pair / Re-pair → transitions an unpaired account to status='pending'
and opens the live QR flow. Works for first-time pair AND for re-pair
of an account that was previously unpaired.
- Unpair → asks the bot to stop the live Baileys session and clean
session files; sets status='unpaired' but KEEPS the row (and its
reminders) so the operator can re-pair without retyping anything.
- Delete → permanently removes the account and cascades to its groups,
reminders, run history.
Schema:
- whatsapp_groups.account_id and reminders.account_id now have
ON DELETE CASCADE so deleting an account fans out cleanly.
UI:
- /accounts list shows everything except the transient 'pending' state.
- /accounts/[id] shows state-aware buttons: Pair (when unpaired/banned/
disconnected), Sync + Unpair (when connected), Delete (always).
- /accounts/new is now an "Add Account" form (label only).
Other fixes:
- next.config.ts: allowedDevOrigins includes 192.168.0.253 +
test/rexwa subdomains so Server Actions work across the LAN.
- packages/shared/src/rrule.ts: rrule@2.8.1 has no exports field and
ships ESM that some bundlers can't resolve via default OR named
import. Use createRequire to bridge — works under both NodeNext
(bot runtime) and Turbopack (web SSR).