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).