Several user-reported bugs and UX nits fixed in one cut:
1. Editing account / when / groups silently dropped messages 2..N
--------------------------------------------------------------
Symptom: a reminder with 3 message parts came back with 1 after
the user edited any section other than the message itself.
Cause: the three section forms were still on the legacy
{text, mediaId, caption} prop shape. The parent pages pulled only
messages[0] from the DB, reduced it to those three fields, and
the form posted them through to updateReminderAction. The action
then folded the legacy fields into a single MessagePart and
replaced the whole reminder_messages row set — wiping parts 2..N
even though the user only meant to change the schedule.
Fix: each form (edit-account / edit-when / edit-groups) now takes
the full `messages: MessagePart[]` and forwards it unchanged. The
three parent pages load the full stack (sorted by position) and
pass it through.
Test: new edit-section-forms.test.tsx asserts a 3-part stack
reaches updateReminderAction intact for both the account-form and
groups-form code paths, plus a sanity test that the legacy
single-message payload shape (without `messages`) is what a
future regression would look like.
2. Reminders list: removed the Group filter
--------------------------------------------------------------
Per request — Account + Search already cover the use cases the
Group filter was supposed to. Search even matches group names
directly, so the dropdown was redundant. Page no longer fetches
the groups table for its filter bar at all.
3. Mobile chrome: bottom nav → top header w/ menu drawer
--------------------------------------------------------------
Removed the bottom tab bar. Mobile now has a single-row top
header:
┌──┐ ┌────┐
│cm│ <current page title> │menu│
└──┘ └────┘
- Brand mark on the left links home.
- Current page title sits in the middle so the user always knows
where they are.
- Menu icon on the right opens a right-side Sheet (radix Dialog)
containing the full nav list. Active item highlighted; the
drawer auto-closes when a nav item is clicked (effect on the
pathname change).
- Theme toggle stays only in the desktop sidebar footer per the
follow-up ask.
Main content padding adjusted: pt-16 (mobile) for the h-14
header, no bottom padding now.
4. Cleaned up the now-unused legacy props
--------------------------------------------------------------
`text` / `mediaId` / `caption` removed from the three section
form prop types. The wizard's URL-state pass-through still
accepts the legacy fields and folds them into the new
`messages` shape on entry, so old bookmarked /reminders/new
URLs still work.
194 passing web tests (was 194; net 0 — the new edit-section-forms
tests replaced coverage we lost when the legacy props went away).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Symptom
-------
The upload action rejected anything over 50 MB with a flat
"File too large (>50MB)" — a number that was both too generous for
images (WA caps at 5 MB) and too restrictive for documents (WA
allows 100 MB). And anything over 1 MB was being rejected even
earlier by Next's default Server Action body limit, with a much
less actionable error.
Fix
---
1. New `lib/whatsapp-media.ts` resolves an uploaded file's MIME type
to a WhatsApp delivery kind and validates it against the
per-kind cap that WA actually enforces:
image → 5 MB image/* except sticker-mode
video → 16 MB video/*
audio → 16 MB audio/*
document → 100 MB anything else (PDFs, office docs, …)
Anything not recognised as image/video/audio falls through to
"document", which is also the Baileys sender path the bot uses
to deliver it. So a .zip or .csv ends up correctly classified
AND correctly limited to the document cap.
Error messages now name the kind and show both the actual size
and the cap: "Image too large (5.2 MB > 5.0 MB limit on
WhatsApp)".
2. `next.config.ts` lifts the Server Action body limit from the 1 MB
default to 100 MB, so document uploads actually reach the action
instead of getting bounced at the framework boundary. The WA
per-kind validator inside the action enforces the real limit
from there.
3. The compose-step upload zone hint now reflects the per-kind caps
("Image up to 5 MB · video / audio up to 16 MB · document up to
100 MB") instead of the wrong flat "up to 50 MB" value.
Tests (17 new cases, total 189)
-------------------------------
- classifyMediaKind: image/video/audio prefix routing, fall-through
to document for unknown / empty / octet-stream / text/plain.
- validateForWhatsApp: at-cap, just-under-cap, just-over-cap for
image (5 MB) / video (16 MB) / audio (16 MB) / document (100 MB);
zero-byte rejected; unknown-mime 60 MB upload accepted as document.
- WA_MAX_BYTES sanity: equals the document cap and is >= every other
per-kind limit (so it's safe to use as the framework body cap).
- formatBytes: bytes / KB (no decimals) / MB (one decimal) rendering.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three small bugs the user surfaced after the swipe rollout:
1. Editing a paused/ended one-off reminder threw "Time is in the past"
----------------------------------------------------------------------
The four edit-section pages (account / message / groups / when) all
POST through `updateReminderAction`. The action's "scheduledAt
must be in the future" check fires on every submit — including the
three section pages that don't change the time and just pass the
original `scheduledAt` straight through. So a user editing the
message body of a reminder they paused yesterday saw their save
rejected with "Time is in the past".
New pure helper `validateUpdateScheduledAt` in lib/reminder-update.ts
keeps the future-time check in place for active reminders that are
actually changing the time, but allows past timestamps when:
- the reminder is paused or ended (won't fire while in those
states regardless of what the row says about scheduledAt), OR
- the submitted timestamp matches the existing one within a
second of rounding (the form is a passthrough).
Tests: 10 cases in `lib/reminder-update.test.ts` covering active
future, active past, paused passthrough, ended passthrough, paused
with deliberate change, sub-second drift tolerance, exact-NOW edge,
null existing scheduledAt, malformed ISO.
Also (drive-by, related): `updateReminderAction` no longer force-
sets `status: "active"` on save. Editing a paused reminder's
message shouldn't silently un-pause it. The user uses Restart for
that.
2. Reminder list reshuffled after Pause/Restart
----------------------------------------------------------------------
The list defaulted to `sort=scheduled_desc`, so clicking Restart on
row N (which moves scheduledAt forward to the next occurrence) flipped
the row to row 0. Felt like the wrong action ran. Fixed:
- Page now hard-codes `sort = "created_desc"` (created_at never
changes, so a row stays where it is).
- Sort dropdown removed from `<ReminderFilterBar>` since it has
nothing to drive anymore. Account + Group filters and the
search box stay.
3. Swipe shelf stayed open after the action ran
----------------------------------------------------------------------
`SwipeableRow` keeps its offset in component state. When a shelf
button submits the form, the page revalidates and re-renders, but
React keeps the same row instance (matched by `key={reminder.id}`),
so the open offset stuck around. Now both row sites encode the
"row state" into the key:
- reminders: `key={\`${reminder.id}-${reminder.status}\`}`
- activity: `key={\`${run.id}-${run.archivedAt ? "1" : "0"}\`}`
Status flip → key change → React unmounts/remounts → offset back
to 0 → shelf closed. Costs nothing (these rows are cheap).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two follow-ups on the activity-row swipe work:
1. SwipeableRow now supports BOTH directions
----------------------------------------
The component grew a `leftActions` slot alongside the existing
right shelf. Drag the row right to pull the left shelf into view
(non-destructive action: Archive, Pause, etc.); drag left to pull
the right shelf into view (destructive: Delete). Past
REVEAL_THRESHOLD (60 px) the corresponding shelf locks open;
below it, snaps closed. Each shelf is opt-in — omit a slot and
the row only swipes one direction.
- `computeSwipeNext` and the new `snapPosition` helper take a
`{ leftActions, rightActions }` flag pair so the math knows
which directions are valid. Drags toward a missing shelf get
clamped to 0 instead of fully open.
- Activity rows wired as iOS-Mail-style: leading edge (right
swipe) = Archive/Restore (amber), trailing edge (left swipe)
= Delete (destructive red).
- Tests grew to 16 cases covering: snap-to-closed below threshold
either way, snap-to-open at/past threshold either way, clamps
don't escape the shelf width, missing-shelf rows don't snap to
a non-existent open state, baseOffset-aware reverse-drag math,
and SSR markup contracts (data-testid, data-state="closed",
translateX(0px), aria-hidden=true on closed shelves, no
orphaned shelf wrapper when only one slot is provided).
Also fixed a `-0` slip in the clamp branch (`-maxRight` is `-0`
when maxRight is 0) so call-site equality checks behave.
2. Reminders list rows are swipeable too
----------------------------------------
/reminders page now wraps each row in SwipeableRow:
- Left swipe → Delete (always available, destructive).
- Right swipe → Pause (when status is "active") OR Restart
(when "paused" or "ended"). Other lifecycle states (failed)
omit the right shelf entirely; the row only swipes one way.
Each shelf button is a tiny `<form>` posting to the existing
server action (delete / pause / restart) — no client-side state
beyond the swipe gesture. Page revalidates after the action,
list re-renders, row redraws in its new state.
Reused the same shelf-button visual language as the activity
tab (color-coded action, icon + label, dark-mode pairs) via a
tiny inline `ReminderShelfButton` helper.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two unrelated bits the user asked for in the same breath:
1. Activity row swipe-to-reveal actions
----------------------------------------
On the mobile activity tab, drag a row left to reveal an Archive
button (Restore when already archived) and a Delete button. Past a
60 px threshold the shelf locks open; below that it springs back.
Tapping anywhere outside an open row closes it. Desktop keeps a
table layout but gains the same two row-level icon-buttons in a
new Actions column, since hover-then-discover is more natural with
a mouse than a swipe.
- New `<SwipeableRow>` (apps/web/src/components/swipeable-row.tsx)
— pointer-events only (no third-party gesture lib), 130 lines.
The drag math lives in a pure helper `computeSwipeNext` so it's
unit-testable without a DOM.
- Migration 0007 adds `reminder_runs.archived_at timestamptz`
(null = visible by default, non-null = archived). Soft-archive
keeps the row queryable under a new "Archived" filter tab; hard
Delete drops the row entirely (run_targets cascade via FK).
- Server actions: `archiveRunAction` / `unarchiveRunAction` /
`deleteRunAction`. Each rate-limits to 30/min/IP. Ownership
check piggybacks on the same operator-or-orphan rule the
activity query already uses.
- `listActivityRuns(operatorId, { archived })` extended to filter
in or out of the archived window. Default is archived: false so
the existing tabs (All / Success / Partial / Failed / Skipped)
keep showing only live runs.
- Tests
* `swipeable-row.test.tsx` — 6 unit tests covering the drag math
(clamp at 0 / -SHELF_WIDTH, snap-to-closed below threshold,
snap-to-open at or past threshold, snap math respects the
previous offset) plus 2 SSR markup contracts (data-testid /
aria-hidden / starts at translateX(0px) / data-state="closed").
* Total web suite: 154 passing (was 146).
2. Send-test toast text trim
----------------------------------------
"Sent ✓ — check the WhatsApp group." → "Sent ✓". The trailing
note told the user something they could already see (they're the
one who clicked Send Test on a specific group). Less noise.
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>
Two small additions:
1. Accounts list "Last connected" line: bumped from a date-only string
("10 May 2026") to a full timestamp with day, month, year, hour
(12-hour with AM/PM), minute, second — same KL timezone, en-MY
locale. Useful for diagnosing recent disconnects vs old ones at a
glance.
2. New \`duplicateReminderAction\` server action plus a fourth card on
the reminder detail's ActionsBar (Pause / Restart / Duplicate /
Delete). The action copies the source reminder's account, groups,
message parts, and schedule (rrule unchanged). The new row starts
\`paused\` so it doesn't fire on top of the original — operator
tweaks the schedule from the detail page and Restarts when ready.
Name is suffixed with " (copy)" (capped at 60 chars).
ActionsBar grid bumped from 3-column to 4-column at lg, with a 2x2
fallback at sm so it doesn't get cramped on narrower screens.
Test mock for actions-bar.test.tsx widened to include the new action.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DialogFooter with showCloseButton now lays out the auto-rendered Close
and the caller's primary action (typically a <form>-wrapped Submit) in
a 2-column grid that's identical at every viewport. Both buttons are
sized "sm" with w-full so they fill their column and match in height.
The trick to making this transparent for callers: \`[&>form]:contents\`
collapses the form box so its <Button> child becomes a real grid item
sibling of the Close button, not a single grid cell containing the
button. \`[&>form>button]:w-full\` then sizes the submit button to
match the Close button's column width.
Five existing call sites pick this up automatically — no changes
needed at the call site:
- reminder pause/restart/delete (actions-bar)
- account unpair / delete
- dashboard "Clear history"
- activity tab "Clear history"
Also: dashboard "Recent activity" now shows the 3 most recent runs
instead of 10. The "Recent runs" stat card description updates to
match ("3 most recent runs"), points to /activity, and a "View all"
ghost link sits beside the section heading so you can jump to the
full history without hunting.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Yearly was a single Month dropdown + a Day number input — one Month and
one Day per rule. That meant "every quarter on the 1st" needed four
separate schedule rows.
Now Yearly mirrors Monthly's grid pattern but with two grids:
Months [Jan][Feb][Mar][Apr][May][Jun]
[Jul][Aug][Sep][Oct][Nov][Dec]
Days [ 1][ 2][ 3]...[31] (7×5 grid)
Both grids are multi-select. Cron output uses the comma-list form on
both DOM and month positions:
months: [1,4,7,10] + days: [1] → "0 9 1 1,4,7,10 *"
months: [12] + days: [24,25,31] → "0 9 24,25,31 12 *"
The cron field is a Cartesian product — every selected day fires in
every selected month. So "every quarter on the 1st" is now one rule.
Round-trip: parser accepts comma-lists for both DOM and month, with
single-element shapes (the old "0 9 13 5 *") still loading fine.
Migration of saved data: old yearly rules with one DOM + one month
parse into monthDays=[X], months=[Y] — identical visual selection in
the new grid, identical cron output. No DB changes needed.
Renamed `Draft.month` to `Draft.months: number[]`. The "Single
day-of-month for yearly" field is gone — yearly now reads
`monthDays` (same as monthly).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Monthly tab is now a 7-column grid of buttons for days 1-31. Tap any
combination to fire the reminder on those days every month — picks
multiple. The summary line reads, e.g.:
"Every month on days 1, 15 at 09:00"
Days that don't exist in some months (29-31) skip naturally — that's
just how the cron DOM field works, no extra plumbing needed.
Cron output uses the comma-list form:
Selected days [1, 15, 28] → "0 9 1,15,28 * *"
The parser now accepts a comma-separated DOM list on the way back in,
so the picker round-trips a saved monthly rule with all selected days
restored. Pre-existing single-day monthly rules (e.g. "0 9 15 * *")
still load fine — the same regex handles both.
Empty-state copy: rewrote the verbose
"Doesn't repeat — fires once at the date and time above."
down to just "No Repeats". The label "Repeats" above the box plus the
"Add a recurring schedule" button below already explain the behaviour;
the long sentence was pure noise.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Daily tab had two radios: "Every day" vs "Every weekday (Mon–Fri)".
That's confusing — Mon-Fri-only is a weekly pattern, not a daily one,
and it overlapped exactly with what the Weekly tab can already do
(select Mon, Tue, Wed, Thu, Fri).
So Daily now means literally every day. The tab body is just the time
picker plus a one-liner ("Fires once a day at the time below.").
Legacy reminders that stored "MM HH * * 1-5" still load fine — the
parser maps any DOW list (including the 1-5 range) onto a Weekly draft
with Mon-Fri pre-selected. So a saved "weekday" daily reminder shows
up as Weekly with the right days checked, no data loss.
RadioRow component went unused after this — removed.
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>
Two changes in one cut, both per the user's redesign asks:
1. Bring the recurrence picker INLINE into the When form section.
The dialog is gone — the type tabs and per-type config now live
directly under the date+time inputs:
[ Starts on ] [ Time ]
Repeats
┌──────────────────────────────────────────────────┐
│ Schedule 1 [✕] │
│ [Daily] [Weekly] [Monthly] [Yearly] │
│ <per-tab config> │
│ Every weekday at 09:00 │
├──────────────────────────────────────────────────┤
│ Schedule 2 [✕] │
│ [Daily] [Weekly] [Monthly] [Yearly] │
│ <per-tab config> │
│ Every Friday at 17:00 │
└──────────────────────────────────────────────────┘
[+ Add another schedule]
2. Allow multiple recurrence rules per reminder. Each row is its own
tab strip + config; the picker compiles them down to a single
newline-joined CRON: rule. Empty list = "Don't repeat" (one-off).
MAX_RULES is 8.
Storage stays the same (`reminders.rrule`, `CRON:` sentinel). The
multi-rule format is just newline-separated cron expressions:
CRON:0 9 * * 1
0 17 * * 5
`@cmbot/shared` updates to support that:
- nextOccurrence: splits on newline, computes the next match for
each rule independently, returns the earliest. Malformed lines
are skipped (so one bad rule doesn't kill the whole schedule).
- validateMinInterval: validates every line; any single line firing
more often than the 5-min minimum fails the whole rule.
Removed: the standalone modal Dialog wrapper, Reset/Cancel/Save
buttons, and the saved-vs-draft synchronisation. The picker now
edits state directly and the parent form's Save commits everything
at once (consistent with the date+time inputs that have always
behaved that way).
Tests (+3 in shared rrule.test.ts; total 20 shared + 26 bot + 132 web
= 178)
- nextOccurrence on a multi-line cron picks the earliest:
* "0 9 * * 1\n0 17 * * 5" starting Saturday → Mon 09:00 KL
* Same rule starting Tuesday → Fri 17:00 KL
- nextOccurrence ignores malformed lines and still returns the next
match from the valid ones.
- validateMinInterval: passes a clean two-line rule; rejects a rule
containing a too-frequent line.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous flat radio list with N-minutes / N-hours / Custom-cron
options is gone. Per the Temenos UUX `date-recurrence-picker` pattern
(developer.temenos.com/uux/docs/components/date-recurrence-picker), the
form now shows a single read-only trigger field summarising the saved
rule:
┌────────────────────────────────────────────────────┐
│ 📅 Don't repeat ▾ │
└────────────────────────────────────────────────────┘
Clicking the trigger opens a modal with the recurrence types as a
tab strip and per-type config swapped in below:
┌──────────────────────────────────────────────────┐
│ Repeat schedule ✕ │
├──────────────────────────────────────────────────┤
│ [Don't repeat] [Daily] [Weekly] [Monthly] [Yearly] │
│ │
│ <per-type config> │
│ │
│ Fires: <plain-text confirmation> │
│ │
│ [Reset] [Cancel] [Save] │
└──────────────────────────────────────────────────┘
Per-tab config:
- Don't repeat — informational text only
- Daily — radio: "Every day" / "Every weekday (Mon–Fri)"
- Weekly — Mon..Sun chip multi-select
- Monthly — day-of-month input (1-31)
- Yearly — month select + day input
The "Fires: …" sentence updates live as the user edits and reflects
the outer time-picker's HH:MM. Save commits, Cancel discards.
Removed:
- Every N minutes
- Every N hours
- Custom cron expression…
- The standalone helpers `flowToCron` / `flowFromCron` /
`freqChoices` / `defaultFlowState` / `FlowState` / `FreqChoice`
in `lib/recurrence.ts`. Their job (compile a UI state to a cron
string and parse one back) now lives privately inside the picker.
Storage / runtime
- Output is still a `CRON:` prefixed rule in `reminders.rrule`. The
bot's `nextOccurrence` already dispatches cron rules through
cron-parser, so no schema or scheduler changes were needed.
Tests (132 web)
- recurrence.test.ts trimmed to keep only what survives: CRON-rule
round-trip via buildRrule + specFromRrule, and the ISO→cron
weekday helper.
- Existing wizard / edit-when-form integration tests are unaffected
because the picker exports the same `<RecurrencePicker>` props.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per the user's ask: stop dumping 12 presets in front of the user. Walk
them through "pick a frequency, then configure it." Each choice
expands its config inline below the radio.
Picker (now 8 top-level choices):
○ Don't repeat (one-off)
○ Every N minutes → number input (1-59)
○ Every N hours → number input (1-23) at :MM
○ Every day at HH:MM (uses outer time picker)
○ Every week at HH:MM → weekday chip multi-select
○ Every month at HH:MM → day-of-month input (1-31)
○ Every year at HH:MM → month select + day input
○ Custom cron expression… → free-form textbox
Behaviour:
- Selecting a row reveals only that row's config; the others stay
collapsed so the screen stays calm.
- HH:MM in every "at HH:MM" label tracks the outer time picker — change
the time and every label updates instantly. Same for the cron
expression the picker emits.
- Every config change recompiles to a single cron string and pushes a
`{ kind: "cron", cron: "..." }` spec up to the parent. Empty weekday
list yields null (config not yet valid).
- Editing an existing reminder calls `flowFromCron(rule, firstFire)`
which reverse-engineers a flow state from the stored cron — including
expanding `1-5` ranges into a weekday chip list — so the right radio
is highlighted and config inputs are pre-populated.
- Anything not recognised by `flowFromCron` (legacy RRULE, hand-rolled
cron) lands on "Custom cron expression…" with the literal expression
in the textbox.
Helpers in `lib/recurrence.ts`:
- `FreqChoice` ("none" | "minute" | "hour" | "day" | "week" | "month"
| "year" | "cron") + `FlowState` interface with all config fields.
- `freqChoices(firstFire)` → first-fire-aware label list for the radio.
- `defaultFlowState(firstFire)` → seeds sensible defaults (today's
weekday, day-of-month, month, etc.).
- `flowToCron(flow, firstFire)` → cron string or null. Clamps
out-of-range integers.
- `flowFromCron(rule, firstFire)` → best-effort reverse mapping.
- `isoWeekdayToCron(iso)` → maps ISO 1-7 (Mon..Sun) to cron 0-6
(Sun..Sat).
Removed: the previous `presetToSpec` / `matchPreset` / `presetDescriptors`
+ `presetCron` family. They're superseded by the flow helpers.
Tests (+11 in recurrence.test.ts; total 139 web + 26 bot + 17 shared
= 182):
- freqChoices order and time-bearing labels
- flowToCron for every freq + config combination, including empty
weekday list returning null
- clamp behaviour for out-of-range minute/month-day/month integers
- isoWeekdayToCron for Mon..Sun
- defaultFlowState seeded fields
- flowFromCron round-trips every flow output exactly
- BYDAY range expansion (1-5 → [1,2,3,4,5])
- unrecognised expressions land on the cron textbox
- buildRrule + specFromRrule still handle CRON: prefixed strings
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per the user's ask: drop the friendly RRULE-based shortcuts (Daily /
Weekly / Custom… etc.) — every selectable preset is now a cron
expression. Schedules are stored in `reminders.rrule` with the
`CRON:` sentinel and dispatched via the existing cron-aware
`nextOccurrence` helper.
Picker
- "Don't repeat" stays at the top (one-off, no cron).
- 11 cron-flavoured presets, each with its underlying cron expression
shown as the hint:
Every minute * * * * *
Every 5 minutes */5 * * * *
Every 15 minutes */15 * * * *
Every 30 minutes */30 * * * *
Every hour at :MM MM * * * *
Every day at HH:MM MM HH * * *
Every weekday at HH:MM MM HH * * 1-5
Every weekend at HH:MM MM HH * * 0,6
Every <DOW> at HH:MM MM HH * * <cron-dow>
Every month on day D at HH:MM MM HH D * *
Every year on Mon D at HH:MM MM HH D M *
- Labels are first-fire-aware: changing the time picker re-derives
every "at HH:MM" label and the preset's canonical cron string.
- "Custom cron expression…" reveals a free-form text input for
anything not covered by the presets.
- Removed: the old "Custom" RRULE detail panel (frequency dropdown,
weekday picker, monthday input, end-condition picker).
Storage
- `presetToSpec("none")` → kind:"none". Every other preset →
kind:"cron" with its canonical cron string.
- `matchPreset` compares the spec's cron expression against each
preset's canonical cron for the current first-fire — falls back to
"cron" (custom textbox) for anything else, including legacy RRULE
reminders that haven't been re-saved yet. Existing RRULE reminders
keep firing on the bot side (nextOccurrence still dispatches both).
- `presetCron(id, firstFire)` is a small pure helper; ISO weekday
(1=Mon..7=Sun) maps to cron weekday (0=Sun..6=Sat).
Tests (+8 in recurrence.test.ts; 137 web + 26 bot + 17 shared = 180)
- presetToSpec emits the right cron for every recurring preset
including Sunday → cron weekday 0.
- matchPreset round-trips through presetToSpec for every preset.
- matchPreset returns "cron" for arbitrary (non-preset) cron strings.
- presetDescriptors lists exactly the cron-only items in order with
first-fire-aware labels ("Every weekday at 09:00", "Every Wed at
09:00", "Every year on May 13 at 09:00", "Custom cron expression…").
- buildRrule produces CRON: prefixed strings for cron presets and
null for "none".
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 list
- New ReminderFilterBar (client component, URL-driven):
* Free-text search across reminder name, first message text,
account label, and target group names. Debounced 250 ms.
* Account dropdown — filters to one paired account.
* Group dropdown — narrows to a single group; auto-scoped to the
chosen account.
* Sort dropdown — Newest first / Oldest first / Recently created /
Name A→Z. Default is `scheduled_desc`.
- Status tabs (All / Active / Ended / Paused) preserve all other
filter params when flipping, so changing tab doesn't lose context.
- Empty-state copy is filter-aware ("No reminders match your filters."
vs "No <status> reminders yet.").
- Pure helpers in `lib/reminder-filter.ts` so the same q+account+
group+status+sort logic can be unit-tested without a DB.
/reminders/[id] detail
- New ActionsBar (Pause / Restart / Delete) replaces the bare delete
button. Each card is a transparent <button> overlay over a Card
(no <button>-wrapping-Card — the static guard keeps it that way).
Confirm dialogs gate every destructive action.
- Pause: visible only when status === "active"; flips to "paused".
- Restart: visible when status is "paused" or "ended". For a
recurring reminder, computes the next occurrence from the RRULE
and re-arms pg-boss; for a one-off reminder it sets the next
fire to "now + 1 minute".
- Delete: always available (run history is preserved on /activity).
Server actions
- `pauseReminderAction(formData)` — sets status="paused" if active.
- `restartReminderAction(formData)` — recomputes next fire and
re-arms via pg_notify(`reminder.schedule`).
- The existing deleteReminderAction is reused.
`lib/queries.ts#listReminders`
- Now also returns accountId, group ids, joined group names, and the
first message text — fields the search/filter logic needs.
- Coerces SQL timestamp strings to Date objects (raw `db.execute(sql)`
hands them back as strings, which broke .getTime() in the sorter).
Tests (+22 new, 130 web tests + 26 bot tests = 156 across the repo)
- lib/reminder-filter.test.ts (16 tests):
* search hits across all four indexed fields, case-insensitive
* account / group / status filters
* every sort key, including handling of null scheduledAt
* combined AND-of-all-filters check
- app/reminders/[id]/actions-bar.test.tsx (6 tests):
* Pause card only shown for `active`
* Restart card only shown for `paused` / `ended`
* Delete card always rendered
* Restart description differs for recurring vs one-off
* every confirm dialog carries the matching `reminderId` hidden input
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The send-test form was stuck on "Sending to <Group>…" because the
server action returns the moment it publishes the IPC NOTIFY; the bot
processed the actual WhatsApp send out-of-band and the form had no way
to learn whether it succeeded.
Round-trip now wired end-to-end:
- New WebEvent variant `send_test.done` { groupId, ok, error }.
- bot/src/ipc/send-test-handler emits it on every exit path:
- missing group → ok=false, "Group not found"
- account offline → ok=false, "Account not connected — re-pair first"
- send threw → ok=false, error message
- send succeeded → ok=true, null
- web/src/hooks/use-events declares the new event in its type map.
- web SendTestForm subscribes via useEvents, filters by its own
groupId so a parallel send-test on another group can't move our
state, and renders one of three pills:
* Sending… (in-flight — Loader2 spinner)
* Sent ✓ (success — emerald CheckCircle2)
* <error message> (failure — destructive AlertCircle)
The "Send Test" button stays disabled while in-flight.
Tests (+5; 110 web tests total):
send-test-form.test.tsx
- SSR markup: textarea, submit button, hidden groupId, no premature
pill on first render.
- useEvents wiring: form registers a `send_test.done` handler.
- Handler safely accepts:
* matching success event
* matching failure event
* mismatched groupId (must not throw)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The pairing page used to show a static skeleton block before the bot
pushed the first session.qr event through SSE — visually quiet, easy
to mistake for a stalled page. Replace it with a labelled, accessible
spinner:
- lucide Loader2 icon with Tailwind animate-spin
- role="status" + aria-live="polite" + aria-label="Generating QR code"
so assistive tech announces it as soon as the page loads
- Same size-64 footprint as the rendered QR — no layout jump when the
image lands
Tests (+5, 104 passing total):
- pair-live.test.tsx: covers the initial 'waiting' state — spinner
attributes, animated icon, helper text, no premature QR/countdown/
Save button, and the size-64 placeholder.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bot/src/ipc/pair-state.ts (NEW)
Pure helpers for the pairing-lifecycle decisions, lifted out of
pair-handler so the rules are testable without Baileys / Postgres:
- decideOnPairClose({ current, loggedOut })
- decideOnPairTimeout({ current })
- shouldAutoReconnect({ loggedOut, hasEverConnected })
bot/src/ipc/pair-state.test.ts (NEW, 7 tests)
Locks in the regressions we just fixed:
- Non-loggedOut close from `pending` MUST settle as `unpaired`
(the row used to stay `pending` and disappear from the overview).
- logged_out close → `logged_out`.
- pair-window timeout parks still-`pending` rows; ignores rows
that already moved on.
- Auto-reconnect only kicks in for accounts that have been linked
at least once — guards against the 5-second QR refresh loop on
a fresh pair.
web/src/components/accounts-list-view.test.tsx
+ Test that the overview renders accounts in transient states
(pending, unpaired, disconnected) alongside connected ones — the
`pending` row was being hidden by listAccounts before this fix.
Bot: 24 tests passing (+7).
Web: 99 tests passing (+1).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Accounts list was hiding any row in the transient `pending` status
(originally meant only for an active QR scan). When a pair attempt
failed (timeout, transient connection error, page closed mid-scan)
the row was left in `pending` and silently disappeared from the
overview — the operator's "I created an account but it's gone" bug.
Two-part fix:
- listAccounts no longer filters by status. The status badge tells
the operator what state each row is in; hiding rows just hides
bugs.
- Pairing lifecycle no longer leaves rows in `pending` after failure.
When the pair-handler sees a close (Baileys exhausting QR refs, or
the pair-window timeout firing), it now sets `status='unpaired'`
and clears `last_qr_png`. The row settles into a state that the
detail page can act on (Re-pair / Delete) and remains visible on
the list.
- The bot startup sweep used to DELETE stale pending rows older than
1 hour. It now parks them as `unpaired` instead, keeping them
visible so the operator notices and can retry.
Stuck `haha` row in the live DB also flipped to `unpaired` so it
reappears on the list immediately.
98 tests passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Account-level destructive actions (Delete, Unpair, Re-pair) live on
the detail page only. The overview is now a calm grid of one card per
account, each linking to its detail page.
- Removed the dedicated Delete card and its dialog from
accounts-list-view.tsx.
- The whole account card is once again the link target — no inline
trigger surfaces, no Dialog component, no destructive click area.
- AccountsListView no longer needs the deleteFormAction prop; the
/accounts page passes only `accounts`.
Tests updated:
- accounts-list-view.test.tsx: 6 tests now (was 8). The two cases that
asserted on the delete card are replaced with one positive test that
asserts no Delete affordance is rendered on the overview, plus a
test that the only `<a>` per cell wraps the card with no inline
buttons inside it.
- no-render-warnings.test.tsx: drops the obsolete deleteFormAction
prop in its renderQuiet calls.
Hydration: live curl on /, /accounts, /reminders, /activity,
/settings and a detail page returns 200 with no Hydration / script-tag
warning in the web logs after this commit.
98 tests passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The remaining "Hydration failed" error came from passing a Card (a <div>)
as the asChild target of Radix's DialogTrigger. Radix's Slot then
injects button-specific props (type="button", aria-haspopup, …) onto
the underlying <div>, and React's SSR vs client trees diverge on those
attributes.
Same overlay pattern that already worked for the Pair card now applies
to every Dialog-card-trigger in the app:
- accounts list — Delete card per row
- account detail — Unpair card
- account detail — Delete card
The visible Card stays a <div>. A real <button type="button"> with no
children sits absolutely-positioned over the card surface and is the
DialogTrigger target. Click area is identical, HTML is valid, no Radix
prop-forwarding into the wrong element type.
Also fixed: edit-account-form.tsx had the original
<button>...<Card>...</Card></button>
nesting (the new static guard caught it). Replaced with a Card that's
its own pressable region (onClick + onKeyDown + role=button on the
<div>; no nested button).
Test guards
-----------
+ src/test/no-render-warnings.test.tsx (6 tests)
Renders AccountsListView, ThemeToggle, EditMessageForm via
renderToString and asserts neither console.error nor console.warn
was invoked. Also scans the produced HTML for any <button> region
that contains a <div>/<p>/<h*> — invalid nesting that would cause
a hydration mismatch in the browser.
+ src/test/no-button-wrapping-card.test.ts (2 tests)
Walks every production .tsx file in src/ and fails if any contains
a literal `<button` (lowercase) that wraps `<Card`/`<CardContent`/
`<CardHeader`. Caught a real instance in edit-account-form.tsx that
I missed in the earlier round.
Total tests: 100.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Root cause of the hydration mismatch:
<button type="button"> ← React 19 server output
<Card> ← <div> from shadcn Card
<CardContent>...
`<div>` is flow content and is NOT allowed inside `<button>` per the
HTML spec. Browsers auto-close the outer `<button>` when they hit the
nested `<div>`, while React's SSR doesn't — the server tree and the
post-parse client tree disagree, and React 19 throws Hydration failed.
Fix: stop nesting Card inside button-shaped triggers. Three sites
touched, all on the account list / detail pages:
- Accounts list — Delete card per row
- Account detail — Unpair card
- Account detail — Delete card
For these the trigger is a Dialog. Radix's DialogTrigger asChild
forwards click handling to whatever element you give it, so we now
pass the Card directly with role="button" / tabIndex / aria-label.
The Card stays a <div>, no invalid nesting.
- Account detail — Pair / Re-pair card
This one wraps a server action `<form>`, which still requires a real
`<button type="submit">`. Solution: keep the Card as a sibling of an
absolute-positioned transparent submit button covering the card's
surface — the whole card surface still triggers submit, but the
visible Card never lives inside the button, and HTML stays valid.
Updated `accounts-list-view.test.tsx` to match: the delete card's
trigger is now a `<div role="button" tabIndex="0">` instead of a
real button.
92/92 tests passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mocks next-themes' useTheme so the component is testable in Node.
Mocks the radix DropdownMenu primitives to render trigger + items
inline instead of through a portal. Coverage:
- Rendered markup picks the correct icon + label for each theme
('light' → Sun, 'dark' → Moon, 'system' / undefined → Monitor).
- All three menu items render under the trigger.
- Each menu item's onClick calls setTheme with the matching value.
Walks the React element tree to grab the onClick handlers without
needing a DOM — keeps the existing react-dom/server testing setup.
Total tests: 92.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
I incorrectly removed next-themes thinking it caused the hydration
warning. The actual mismatch was a `__gcrremoteframetoken` attribute
added to <html> by a browser extension, which the previous commit
already addressed via `suppressHydrationWarning`.
Restored:
- ThemeProvider wrap in the layout
- ThemeToggle component
- Sonner Toaster's useTheme() so toasts respect the chosen theme
- Appearance card on the Settings page
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The remaining hydration mismatch was a stray `__gcrremoteframetoken`
attribute added to <html> by a browser extension after the document
loaded. Browser extensions (password managers, accessibility tools, the
Google iframe-token injector, Grammarly, etc.) routinely poke at the
top-level elements before React hydrates and React 19 then flags it as
a mismatch even though our code wasn't involved.
`suppressHydrationWarning` on <html> and <body> only suppresses
**attribute** differences on those elements; their children continue
to be hydration-checked normally, so any real bugs still show up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
next-themes hydration mismatch
- Removed the next-themes wrapper, ThemeProvider component, and the
Settings appearance card — there's no theme-toggle UI anywhere in
the app, so the library was just adding a pre-hydration `<script>`
that triggered React 19's "script tag while rendering" warning and
the `<html>` class swap caused the hydration mismatch.
- Sonner Toaster now uses a fixed `theme="light"` instead of useTheme.
- Layout drops `suppressHydrationWarning` on `<html>` since we no
longer mutate it on mount.
QR refs exhausted before the user could scan
- Pass `qrTimeout: 60_000` to makeWASocket so each QR (first AND
subsequent) lasts a full minute. Default was 60 s for the first and
20 s for each subsequent → ~6 refs × default = ~2.5 min before
Baileys gave up. With 60 s flat, the user has the full ~5 min
window matching pair-handler's PAIR_TIMEOUT_MS.
Pairing-timed-out screen
- "Try again" used to link to /accounts/new (creates a new account
instead of re-pairing the existing one). Link now points to the
existing /accounts/[id] detail page where the operator can hit
Re-pair.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
QR / pairing
- Replace the per-QR 30 s countdown with a single pairing-window timer
matching the bot's PAIR_TIMEOUT (5 minutes). Baileys naturally rotates
QR images every ~5 s — the previous 30 s bar reset on every rotation,
which felt like a constantly-cycling timer to the user.
- The new timer starts on the first QR and ticks down once; later QR
rotations refresh the displayed image but leave the countdown alone.
- Added a hint: "The QR rotates automatically every few seconds — scan
whichever one is showing." Format switches to MM:SS.
- countdownRender's danger threshold scales: 10 s for short windows
(≤ 60 s), 30 s for the multi-minute pairing window, so the warning
flash appears while the user can still react.
Reminder filter tabs
- Tabs are now: All / Active / Ended / Paused. "Failed" is dropped —
reminder.status doesn't carry "failed" (run statuses do; that view
belongs in /activity?filter=failed).
Tests (+4 = 84 passing total)
- qr-dedupe.test.ts: extended with a "pairing-window scaling" suite
covering pct/danger/expired at 5-minute scale and the threshold split
between short and long windows.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per-section reminder editing
- Replace the wizard-redirect edit shell with four focused single-form
pages: /reminders/[id]/edit/{account,message,when,groups}.
- Each click on a section card on the detail page goes straight to the
matching focused editor — no stepper, no other sections, no
wizard chrome. Save returns to the detail page.
- New form components live under components/reminder-edit/:
EditMessageForm, EditWhenForm (full recurrence builder reused),
EditGroupsForm, EditAccountForm. All submit via updateReminderAction
with the existing values for untouched fields. Switching account
clears group targets (groups are scoped per account; the form warns
and the user re-picks groups afterwards).
Activity tab
- New "Activity" item in the bottom nav + sidebar (between Reminders
and Settings).
- /activity page: full run history (last 200), filter tabs (All /
Success / Partial / Failed / Skipped), clickable rows that open the
underlying reminder, and a Clear history dialog. Mirrors the
dashboard's Recent Activity widget but with deeper data and its own
empty-state messaging.
Tests (+20 — 80 passing total)
- qr-dedupe.test.ts: 14 tests covering the makeQrDedupe factory (per-
account, fresh QRs always emit, reset/scope) and countdownRender
(the QR-expired timer logic — danger threshold, expired flag,
clamping). The dedupe + countdown logic is now used by pair-handler
and pair-live.
- reminder-edit/edit-message-form.test.tsx: 6 tests verifying the form
pre-fills, hides/shows the caption based on attachment, renders the
Save (not "Schedule reminder") action, and the action receives the
expected payload shape for both text-only and media-attached paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dashboard
- Stat cards are now clickable: Accounts → /accounts, Active reminders →
/reminders?filter=active, Recent runs → /reminders.
- Recent activity rows link to the underlying reminder when it still
exists. Runs whose reminder has been deleted render with a "(deleted)"
marker and stay non-clickable.
- New "Clear history" action wipes all run rows the operator owns plus
any orphan rows (reminderId=NULL).
Run history persists after reminder delete
- reminder_runs.reminder_id is now nullable with ON DELETE SET NULL, so
deleting a reminder no longer cascade-erases its history.
- New reminder_runs.reminder_name column snapshots the name at fire
time so history rows stay readable even after the reminder is gone.
- Fire-reminder records the snapshot.
- Dashboard query LEFT JOINs and COALESCEs name from the live reminder,
the snapshot, or "(deleted reminder)" as last resort.
QR
- Drop the 25 s server-side throttle. With listener accumulation already
fixed (previous commit), the payload-equality dedupe is enough.
Symptom: after the first QR expired the throttle blocked the next
emit, and the QR never refreshed.
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>
Refactor the /accounts page into a thin data-fetching shell plus a
pure presentational AccountsListView. The view has no DB or server-
action dependencies (the deleteFormAction is passed in), which makes
it directly unit-testable.
Tests use react-dom/server's renderToStaticMarkup — no jsdom or DOM
testing-library needed. next/link and the radix Dialog are mocked to
plain wrappers so the markup is deterministic.
Coverage:
- one cell per account, each with one main account-card and one
delete-card
- main card links to /accounts/[id]
- account label appears in main card, delete card description, and
the destructive confirm dialog
- delete card is a <button> with the right aria-label
- delete dialog form has a hidden accountId input matching the row
- phone number renders when paired; "Not paired yet" when not
- header CTA renders an Add Account link
- empty state replaces the grid and still offers Add Account
vitest config: include src/**/*.test.{ts,tsx} and switch esbuild jsx
to "automatic" so test files don't need a React import.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the corner trash icon with a separate destructive-themed card
stacked beneath each account card. Whole card is the confirm-dialog
trigger.
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>
Surface state-aware quick actions directly on each card so the user
doesn't have to drill into the detail page just to delete or re-pair an
account. Re-pair shows when status != connected; Delete (with
destructive confirm dialog) is always available.
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).
Two related fixes:
1. Phone (and any LAN client) couldn't reach the web container because
the dev compose mapped 127.0.0.1:WEB_PORT instead of binding all
interfaces. Drop the loopback prefix.
2. Turbopack and NodeNext disagree on extension handling: bot's tsc
needs `.js` extensions in source imports; Turbopack's transpilePackages
path can't resolve those `.js` requests back to `.ts` source. Switch
to consuming the workspace packages via their compiled dist instead:
- packages/db + packages/shared point `main`/`exports` at ./dist/*
- drop transpilePackages from next.config.ts; web picks up the
compiled `.js` files directly
- dev compose command for web builds shared+db before running
`next dev` so dist is fresh when Turbopack starts
- put the `.js` extensions back in packages/db source so NodeNext
compilers (bot's tsc, packages/db's own tsc) are happy