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>
Found from the live bot log: after the user scans the QR, Baileys
receives `pair-success`, logs "pairing configured successfully, expect
to restart the connection...", and then closes the websocket with
status 515 (DisconnectReason.restartRequired) so it can reopen with
the new credentials. The next `open` event finishes the pairing.
The previous code path treated ANY close during pairing as a failure:
it parked the row as `unpaired`, wiped the QR, and emitted
session.timeout to the UI. The user was greeted with "Pairing timed
out — The QR window closed before a device was linked" at the exact
moment they had successfully paired.
Three changes:
- session.ts emits `restartRequired: boolean` on the SessionEvent close
payload (true when reason === DisconnectReason.restartRequired).
- pair-handler treats the restart-required close as a no-op: keeps the
listener attached and the DB row in `pending` so the upcoming `open`
event flips it to `connected`.
- session-manager always reconnects on restart-required (250 ms after
the close — no `lastConnectedAt` gate, no 5 s back-off).
Pure helpers (`pair-state.ts`) updated to model the new branch:
- decideOnPairClose returns null when restartRequired (don't touch DB).
- shouldAutoReconnect returns true on restartRequired regardless of
whether the account has ever connected before.
Tests (+1; 26 bot tests, 104 web tests = 130 green):
- pair-state.test.ts gains explicit cases:
* restart-required close → null
* shouldAutoReconnect always true on restart-required (incl.
first-time pair, where hasEverConnected is false — the exact
case that broke in production).
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>
The earlier "QR refreshes every 5 s" bug was the session-manager
auto-reconnect loop (commit 4d10c72), not the QR cadence. Baileys'
default QR rotation (60 s first ref, then ~20 s per subsequent ref) is
the correct native behaviour — each rotation just refreshes the
displayed QR via SSE. Forcing qrTimeout=60s suppressed those legitimate
rotations and made the QR feel stuck.
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>
The session-manager's auto-reconnect (5 s after a non-logged-out close)
was firing during initial pairing. Baileys closes the socket whenever it
exhausts its QR refs (or transient handshake errors); the auto-reconnect
then opened a brand-new socket → new QR pool → another close 5 s later.
The web saw a fresh QR every ~5 s and the user could never link, because
WhatsApp invalidates each QR as soon as Baileys cycles to the next.
Fix: only auto-reconnect for accounts that have been linked before
(`whatsapp_accounts.last_connected_at IS NOT NULL`). For brand-new
pairing attempts the pair-handler's 5-minute window is now the single
authority; on close we just stop the session and let the operator
retry. With auto-reconnect off, Baileys uses its default QR cadence:
60 s for the first QR, 20 s for each subsequent rotation, ~6 refs total
(~3 minutes of valid scanning) — plenty of time to scan.
Pair-handler now also surfaces ANY close as `session.timeout` to the
web (was only emitting on `loggedOut`). Without this the user would be
left staring at the last QR after Baileys gives up, with no way to know
pairing failed.
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
- Local dev WEB_PORT now 9000 to match the planned dev/staging domain
test.04080616.xyz. Production deployment uses port 8100 mapped to
rexwa.04080616.xyz (configured in plan 4).
- apps/bot/tsconfig.json: exclude src/**/*.test.ts from the production
build (vitest types only present at dev time, mirrors the same fix we
made earlier for packages/shared).
Three small build-time fixes surfaced when the Docker images first ran
their full production build (previously only dev mode via tsx):
- packages/shared: exclude *.test.ts from tsc (vitest types not needed
for shipped output), add @types/node dep so node:crypto resolves
- packages/db: add @types/node dep for the same reason
- apps/web: pin Next.js Turbopack root to the workspace root via
next.config.ts so the bundler doesn't fail to detect the monorepo
layout from inside the Docker image