195 Commits

Author SHA1 Message Date
c5339abe1a feat(bot): fan-out tuning env vars
BOT_FIRE_CONCURRENCY (8) — pg-boss worker pool size, gates max
accounts firing fan-outs in parallel.
BOT_GROUP_CONCURRENCY (3) — per-account parallel group sends; parts
within a group stay serial so chat order is preserved.
BOT_MAX_SEND_PER_MINUTE (40) — per-account token-bucket rate.

Defaults are tuned for an established WhatsApp account
(~30-60 msg/min safe band).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:35:26 +08:00
082a70db06 docs: consolidate windowed-fanout spec/plan with ETA + paused/resume
Folds in three rounds of requirement evolution:

* Pause/resume on window close (was stop-and-report-partial).
* ETA preview pill at compose / edit time so the operator sees
  whether their chosen window will fit before scheduling.
* Interactive paused-run banner with Resume / Cancel buttons on the
  detail page; pause notification deep-links to it.

Helper relocations:

* windowEndAt() moves to packages/shared so both bot fire-reminder
  and the web ETA pill can import the same calculator.

Plan grows from 8 to 10 tasks: adds Task 9 (run-eta + RunEtaPill,
TDD) and Task 10 (resume/cancel actions + PausedRunBanner).
Acceptance gains two paused-flow smoke tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:33:51 +08:00
c4d4f1dda7 feat(web): extract reminder name to its own edit section
The name input previously lived inside the message edit page. Now that
it's a required field — and one users may want to revise without
touching the message stack — it gets a dedicated card on the reminder
detail page and its own edit route at /reminders/[id]/edit/name.

EditMessageForm receives the name as a pass-through prop so saving
messages doesn't drop the existing name from the action payload.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:23:23 +08:00
d5b8c0beeb feat(reminders): name is required (was optional with auto-derive)
Previously the name field auto-derived from the first text part when
the operator left it blank. That's brittle once reminders carry
multiple parts of varying provenance, and confusing in lists where
"Reminder" or partial sentences crowd in.

Now: every reminder must carry a non-empty name, capped at 60 chars.

  - Zod schema on createReminder/updateReminder: name moves from
    `z.string().nullable().optional()` to
    `z.string().trim().min(1, "Give the reminder a name").max(60)`.
    Stale-URL legacy callers that omit it now get a clear server error.
  - Wizard compose step: input has `required` + `aria-required`,
    placeholder + label simplified ("(optional)" tag and the helper
    paragraph removed), Continue blocks on empty.
  - Edit-message form: same — required, aria-required, save blocked
    on empty, the "leave blank and we'll auto-derive" hint dropped.
  - Review-submit client: defensive fail-fast for stale-bookmark URLs
    that arrive at step 5 without a name — bounces back with
    "Give the reminder a name (back on the Message step)" instead of
    letting the server reject.

The resolveReminderName helper stays put — duplicateReminderAction
and any future caller still benefit from the trim+clamp+fallback
chain. Helper unit tests unaffected (they test the resolver in
isolation, the policy-tightening lives at the schema layer above).

298 web tests still passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:15:16 +08:00
50187a86e1 docs: design spec — windowed, pacing-safe reminder fan-out
Records the design decisions for the next planned work:

  - Per-reminder delivery window (default 6am–6pm, operator timezone).
    Window-close hard-stops the run; remaining targets become
    skipped; status reports as partial with a clear "this account is
    at capacity, consider another paired account" message.
  - Per-account isolation via pg-boss teamSize ≥ N + an in-process
    PerKeyMutex keyed by accountId. Different accounts run in
    parallel; the same account serialises (no double-rate sends
    that would risk a ban).
  - Per-account token-bucket rate limiter (default 40 msg/min,
    BOT_MAX_SEND_PER_MINUTE).
  - Up-front media-upload cache via prepareWAMessageMedia: 1000
    groups × 5 MB upload turns into 5 MB. Biggest single win for
    text+picture reminders.
  - Bounded group concurrency (default 3 in-flight per account);
    parts-within-a-group stay serial for visible message order.
  - Pre-fetched DB Maps (groups / messages / media), no inner-loop
    round-trips.
  - Replaces the rigid 1.5 s inter-part sleep with 200–500 ms
    jitter; the per-account rate-limiter is the real gate.

Out of scope for v1 (documented under "v2 candidates"): cross-day
window resume, mid-restart resumability, multi-account auto-split,
adaptive rate-limit back-off, pause/resume mid-run.

Acceptance: 1000-group reminder + one image, established account
finishes in ~30–50 minutes, well inside a 6am–6pm window. Two
reminders on different accounts at the same wall-clock minute
both progress in parallel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:01:24 +08:00
5d91b904f2 refactor(ui): hide page-title H1 on mobile (header already shows it)
Mobile header strip carries the current section title in its centre
slot ("Dashboard" / "Reminders" / etc.). The top-level pages were
ALSO rendering the same string in an H1 right below — duplicate
labelling, wasted vertical space, and the H1 was the first thing
that overlapped the header on tight viewports.

Switched the four duplicates to `hidden sm:block`:
  - / (Dashboard)
  - /reminders
  - /activity
  - /settings

Desktop sidebar has no per-page title chip, so the H1 stays visible
sm: and up. Sub-pages (account detail, group detail, reminder
detail, "New Reminder", "Add Account") have dynamic H1s that don't
duplicate the header — those keep their visibility unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:45:01 +08:00
68d3de5ee2 feat(reminders): user-supplied name; auto-derived as fallback
Reminders pick up a real, user-controlled name instead of being
auto-named from the first message body. Auto-derive stays as the
fallback so empty inputs still produce something useful.

Resolution policy (single source of truth in lib/reminder-name.ts)
------------------------------------------------------------------
1. User-supplied name, trimmed, clamped to 60 chars.
2. First text-bearing message part — text body or media caption,
   trimmed, clamped to 60.
3. Literal "Reminder" (only if every part is media-without-caption
   and no name was given).

Wizard
------
- New "Name" input above the message stack on step 2 (Compose).
  Optional (label says so), maxLength 60, placeholder gives an
  example. Blank flows through the URL as an absent param.
- The name parameter passes through every subsequent step
  (when, groups, review) via the existing URL-state pattern.
- Review step gains a "Name" row at the very top showing what the
  resolver will produce. If the user left it blank, the row shows
  the auto-derived value plus a muted "(auto from message)" tag so
  they know what's happening.

Edit forms
----------
- `EditMessageForm` gains the same Name input at the top —
  consistent with the wizard's compose step.
- `EditAccountForm` / `EditWhenForm` / `EditGroupsForm` accept the
  current `name` and forward it unchanged on save. Otherwise saving
  any of those sections would re-auto-derive the name from the
  message body, silently overriding what the operator typed.

Server action
-------------
- Both `createReminderAction` and `updateReminderAction` accept an
  optional `name` field on the schema. The body collapses through
  the new `resolveReminderName` helper, replacing the inline
  `firstLabel ?? "Reminder"` slice.

Tests (+17 new in lib/reminder-name.test.ts)
--------------------------------------------
- User priority: user name wins over message body even when both
  are present; trimming.
- Auto-derive: first text part, first non-empty after skipping
  empties, media caption when present, trims around the value.
- Fallback: null/undefined/empty stack, every-part-empty, every
  part media-without-caption.
- Clamping: user-supplied long names truncate at 60; auto-derived
  long names truncate at 60; short names pass through.
- The 60-char ceiling matches what the wizard's <Input maxLength>
  enforces and what the DB column allows.

Existing tests updated to pass the new required prop (`initialName`
on EditMessageForm, `name` on EditAccountForm/EditGroupsForm SSR
fixtures, plus a couple in no-render-warnings.test.tsx).

Total: 298 web + 31 shared + 26 bot = 355 passing (was 338).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:43:22 +08:00
8f2ee5df9e feat(web): browser notifications for reminder + send-test events
In-tab notification bridge so the operator gets a system notification
when a reminder fires successfully (or partly / fails) and when a
send-test message lands. Foundation for true background push later
(VAPID + service-worker subscription); this lands the wiring so
behaviour is testable today.

Pieces
------
- `lib/notifications.ts` — pure helper module:
    * notificationSupport / getPermission — feature detection that
      treats the SSR / unsupported-browser case as "denied" so callers
      don't have to handle a third state.
    * isOptedIn / setOptedIn — localStorage-backed opt-in flag
      (key `cmbot.notifications.optedIn`). Survives gracefully when
      window is missing or storage throws (private mode / quota).
    * showNotification(opts) — gated dispatch returning a discriminated
      result ({ ok: true, tag } | { ok: false, reason }) so callers
      can fall back to a UI toast on opt-out / unsupported / error.
    * reminderFiredToNotification + sendTestDoneToNotification —
      pure mappers from the bot's SSE events into notification args.
      Skips bookkeeping noise (status === "skipped") and failures
      that the in-page toast already shows verbatim.

- `components/notification-manager.tsx` — client component mounted
  once at the app shell. Subscribes to `reminder.fired` and
  `send_test.done` via useEvents and forwards each through the pure
  mappers. Renders no DOM.

- `components/notifications-toggle.tsx` — settings-page card with
  three states (unsupported / not-granted / granted+opted-in).
  "Send test" button fires a sample notification so the operator
  can verify the wiring without waiting for a real reminder. The
  blocked-by-browser path points them at site settings instead of
  silently doing nothing.

- `app/settings/page.tsx` — new "Notifications" card sits above
  the Appearance card.

- `app/layout.tsx` — `<NotificationManager />` rendered alongside
  `<Toaster />` inside ThemeProvider so the SSE subscription is
  active across all routes.

Bot side
--------
- `apps/bot/src/scheduler/fire-reminder.ts` — emits
  `pgNotifyWeb({ type: "reminder.fired", reminderId, runId, status })`
  after every run regardless of success/partial/failed. The web
  side decides whether to surface it as a notification (skipped is
  filtered out client-side).

- send_test.done was already emitted by `ipc/send-test-handler.ts`.

PWA service-worker tests (the original ask before this thread)
--------------------------------------------------------------
- Extracted the Serwist config into `pwa/config.ts` so the choices
  (skipWaiting, clientsClaim, navigationPreload, runtimeCaching,
  precacheEntries) are pinnable without booting a worker scope.
- 6 tests in `pwa/config.test.ts` lock the surface (no extra keys
  appear silently, the manifest passes through unchanged, the
  pinned booleans stay where production expects them).
- 6 tests in `app/manifest.webmanifest/route.test.ts` cover the
  manifest contract (display=standalone, start_url=/, dark theme
  colors match the OS, both icons are PNG + maskable, paths
  match committed PNGs in public/).

Test counts
-----------
281 web + 31 shared + 26 bot = 338 total (was 306).

  - +6 pwa/config (service-worker config pinning)
  - +6 app/manifest.webmanifest (PWA manifest contract)
  - +20 lib/notifications (full coverage of mappers + dispatch
        gates + SSR / unsupported / blocked / opted-out paths)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:30:40 +08:00
29535d6bbc docs: refresh README for web-first state of the world (P3/T24)
The status section reflected the post-Plan-1 world where pairing
went through Telegram. Reality has long since moved on:

  - Plans 1, 2, and 3 are all complete.
  - The Telegram bot is gone (removed in P3/T4).
  - The web app at wabot.04080616.xyz is the primary surface.
  - The recurrence picker, multi-message stack, swipe rows, archive
    flow, HEIC fallback, and PWA install all shipped.

Other updates:
  - Quick-start no longer mentions Telegram tokens — replaced with
    the URL the operator opens to use the app.
  - Architecture paragraph swapped grammy mention for the LISTEN/
    NOTIFY consumer + SSE shape.
  - Pointed at the new design spec
    (2026-05-09-web-app-design.md) and manual test runbook.
  - Layout / scripts tables trimmed: dropped the publish.sh and
    link-account.sh stubs that were never implemented.
  - "Deferred" section lists honest gaps (media library, E2E tests,
    auth, multi-operator).

Test counts called out (249 web + 31 shared + 26 bot = 306) so the
status claim is verifiable in a single grep.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:23:04 +08:00
d407fc585e docs: manual web app end-to-end test runbook (P3/T23)
13 sections covering every flow that unit tests can't:

  1. Smoke (mobile header, sidebar, page titles, tab strips)
  2. Account pairing (live QR, sync groups, send-test)
  3. Re-pair / unpair (status flips, no spurious reconnect)
  4. Reminders — single-message one-off (5-step wizard)
  5. Reminders — multi-message stack with media swap
  6. Reminders — recurrence picker (Daily / Weekly / Monthly /
     Yearly + multi-rule, descriptions are sentences not raw cron)
  7. Reminders — edit each section preserves the message stack
     (regression guard for the parts-2..N drop bug)
  8. Reminders list — swipe + lifecycle (no reshuffle on Pause /
     Restart, shelf collapses after action)
  9. Activity — swipe archive / delete + Archived tab + restore
 10. Send-test feedback round trip (Sending… → Sent ✓)
 11. Media upload limits (size caps, HEIC/MOV → document fallback)
 12. PWA install (standalone display, offline shell)
 13. Theme toggle (desktop only)

Captures the policy decisions that aren't immediately obvious from
the code:
  - browser-extension hydration warnings are expected, app code is
    not at fault — Incognito clears them
  - HEIC/MOV uploads are NOT rejected; they fall back to document
    delivery
  - cron rules render as descriptive sentences in the UI
  - status changes don't reorder the reminders list

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:22:04 +08:00
272fbcfa8a feat(web): PWA via @serwist/next + manifest + icons (P3/T22)
The web app is now installable on a phone home screen with offline
fallback for static assets and the navigation shell.

Pieces
------
- `src/app/manifest.webmanifest/route.ts` — dynamic manifest route.
  Standalone display mode, portrait orientation, dark theme matching
  the app, "any maskable" icons so the same PNG works for both
  regular launchers and Android adaptive icons.

- `src/pwa/sw.ts` — service worker entry. Uses serwist's stock
  recipe: skipWaiting + clientsClaim so a new worker takes over on
  the next navigation, navigationPreload to race the network with
  the worker boot, and `defaultCache` for HTML-network-first /
  static-cache-first / image+font cache TTLs.

- `next.config.ts` — wraps the existing config with `withSerwistInit`.
  Disabled in development (`NODE_ENV !== "production"`) because a
  service worker on every dev reload makes hot-reload extremely
  flaky.

- `package.json` build script switched to `next build --webpack`.
  `@serwist/next` doesn't yet support Turbopack (it logs a warning
  and silently skips emitting `sw.js`), and Next 16 defaults the
  build to Turbopack. The dev server still uses Turbopack — only
  production builds switch to webpack.

- `src/app/layout.tsx` metadata gains `manifest`, `icons.icon` (192
  + 512 PNG), and `icons.apple` (180 PNG). The existing
  `appleWebApp.capable` already opts iOS into standalone mode.

Icons
-----
Generated by a tiny one-shot script (`scripts/gen-pwa-icons.ts`)
that uses the workspace's already-installed sharp to render an SVG
wordmark at 512 / 192 / 180 px. Placeholder branding (dark square
with "cm" wordmark) — swap in real artwork later by editing the SVG
in the script and re-running `pnpm --filter @cmbot/web run gen:icons`.

Build artefacts
---------------
- `apps/web/public/icon-512.png`, `icon-192.png`,
  `apple-touch-icon.png` ARE committed (stable input).
- `apps/web/public/sw.js` and `swe-worker-*.js` are NOT — they're
  regenerated on every production build. Added to `.gitignore`.

Verification
------------
- Production build emits `[serwist] Bundling the service worker
  script with the URL '/sw.js' and the scope '/'...` and `sw.js`
  shows up in `public/`.
- `/manifest.webmanifest` is in the build's static-route table.
- 249 web tests still passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:20:45 +08:00
cfd3308477 feat(media): unsupported image/video/audio formats fall back to document delivery
Old behaviour: HEIC/AVIF photos, .mov / .webm / .mkv videos, and niche
audio (FLAC, etc.) got rejected outright at upload with "Images are
not supported" / "Videos are not supported" errors. Strict but
unfriendly — recipients could still receive these as a downloadable
file via WhatsApp's document path; we just weren't using it.

New behaviour: anything not playable inline gets routed through the
document path automatically. The recipient downloads the file and
opens it in their default app. The 100 MB document cap applies
instead of the inline 5 / 16 / 16 MB caps. Only oversized uploads
get rejected.

Where the policy lives
----------------------
The classifier moved into a new `@cmbot/shared/whatsapp-media`
module so the web upload validator AND the bot's fire-reminder send
path share one source of truth:

  - resolveDeliveryKind(mime, bytes?) → "image" | "video" | "audio"
    | "document". Native types stay as-is; HEIF / AVIF / QuickTime /
    WebM / Matroska / non-MP3-or-M4A audio all collapse to "document".
  - Bytes argument is optional but recommended — sniffing the first
    12 bytes of the file catches iOS Safari's habit of labelling
    a HEIC as image/jpeg or a .mov as video/mp4. Bytes win when they
    disagree with the mime.

Web side
--------
- `lib/whatsapp-media.ts` re-exports the shared helpers and keeps
  only the validator + byte-formatter. `validateForWhatsApp` calls
  resolveDeliveryKind internally; the size cap it returns is for the
  RESOLVED kind (so a HEIC routes to document and gets the 100 MB
  cap). The "Images are not supported" / "Videos are not supported"
  rejection messages are gone — there's no format rejection anymore.
- `actions/media.ts` collapses the previous explicit-mime + byte-sniff
  pair into a single `validateForWhatsApp(mime, size, bytes)` call.
- Compose-step upload-zone hint updated to spell out the per-kind
  caps: "JPEG/PNG up to 5 MB · MP4/3GP up to 16 MB · MP3/M4A/OGG
  up to 16 MB · documents up to 100 MB".

Bot side
--------
- `fire-reminder.ts` reads the first 12 bytes of the file before
  dispatching and calls `resolveDeliveryKind(mimeType, head)` to
  pick the senderKind. So a HEIC on disk (whose mime claims
  image/jpeg) gets sent via Baileys' document path — no failed
  thumbnail extraction, message arrives as a downloadable .heic.
- New `readHeadBytes(filePath, n)` helper opens, reads N bytes,
  closes — no full-file slurp.

Tests
-----
249 web + 31 shared + 26 bot = 306 passing total.

Web (`lib/whatsapp-media.test.ts`):
- "HEIC at 30 MB allowed: routes to document (100 MB cap)"
- "HEIC at 110 MB rejects: exceeds the document cap"
- "MOV at 50 MB allowed (would be 16 MB cap as video, 100 MB as
  document)"
- "MOV pretending to be mp4 demotes to document (50 MB allowed)"
- "FLAC audio routes to document path"
- "genuine MP4 byte-sniff path keeps it as video"

Shared (`packages/shared/src/whatsapp-media.test.ts`, new):
- The cross-package contract: 11 tests covering size limits,
  classifyMediaKind, resolveDeliveryKind for native + demoted +
  byte-sniff cases, plus the underlying helpers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:07:54 +08:00
c7a6f5f1b0 feat: humanise cron in list summaries; magic-byte detect HEIC; sidebar brand link tests
Three threads from the recent UX iteration:

1. Reminder list / detail no longer shows raw "Cron: 32 11 * * *"
   ----------------------------------------------------------------
   `describeRecurrence` for a kind=cron spec used to emit
   "Cron: <expr>" verbatim, which is unreadable on the list row's
   recurrence line.

   New pure helper `describeCronRule(rule)` parses the cron shapes
   the recurrence picker produces and renders them as natural
   sentences:

      "0 9 * * *"             → "Every day at 09:00"
      "0 9 * * 1-5"           → "Every week on Mon, Tue, Wed, Thu, Fri at 09:00"
      "0 9 * * 1,3,5"         → "Every week on Mon, Wed, Fri at 09:00"
      "0 9 1,15 * *"          → "Every month on days 1, 15 at 09:00"
      "0 9 13 5 *"            → "Every year in May on day 13 at 09:00"
      "30 17 1,15 1,4,7,10 *" → "Every year in Jan, Apr, Jul, Oct on days 1, 15 at 17:30"

   Multi-line rules ("0 9 * * 1\n0 17 * * 5") join the per-line
   descriptions with " · " for compactness in the list density.

   Long DOM lists (>6 days) collapse with a "+N more" tail to keep
   the line short; same convention the picker's per-row preview uses.

   Unrecognised shapes (e.g. "*/5 * * * *") fall back to the raw
   expression — better than swallowing entirely.

2. HEIC/AVIF magic-byte sniffing at upload
   ----------------------------------------------------------------
   The mime-only check we shipped earlier missed iOS Safari's
   habit of uploading HEIC photos with Content-Type: image/jpeg.
   The file then made it to the bot, where Sharp's HEIF decoder
   plugin is missing, the thumbnail extraction failed, and the
   message went out without a working preview — read by the user
   as "image still not send".

   New helper `sniffUnsupportedImage(bytes)` reads bytes 4..11 of
   the upload and looks for the ISOBMFF "ftyp" marker followed by
   one of the brands Sharp can't decode (HEIF: heic / heix / hevc
   / heim / heis / mif1 / msf1; AVIF: avif / avis). Brand match is
   case-insensitive. Plain JPEG / PNG / unrelated ftyp brands like
   mp4 are not flagged.

   `uploadMediaAction` now runs the sniff against the buffered
   bytes before persisting, returning the same "Images are not
   supported, please re-upload images" error as the mime path.

3. Sidebar brand link → dashboard tests
   ----------------------------------------------------------------
   Asserts the desktop <aside> contains an <a href="/" aria-label=
   "Go to dashboard"> at the top, scoped via a new extractSidebar
   helper so it can't accidentally match the mobile-header brand
   link (which uses aria-label="Go home"). A second test confirms
   the two aria-labels stay distinct.

22 web test files / 232 passing (was 212):
  - +12 cron-description cases in lib/recurrence.test.ts
  - +6 magic-byte sniff cases in lib/whatsapp-media.test.ts
  - +2 sidebar-brand-link cases in app-shell.test.tsx

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:59:13 +08:00
ce42a89af5 test(app-shell): cover sidebar brand link to dashboard
Two new SSR tests in `app-shell.test.tsx`:

- Desktop sidebar's brand header is now a link, not a static <div>.
  Asserts the <aside> contains an <a href="/" aria-label="Go to
  dashboard"> at the top. The slice is scoped to the <aside> via a
  new `extractSidebar` helper so it can't accidentally match the
  mobile-header brand link.

- Mobile and desktop brand links carry distinct aria-labels
  ("Go home" vs "Go to dashboard"). On a wide window where the
  desktop sidebar is visible alongside the (sm:hidden) mobile
  header — which technically can't happen at any one breakpoint
  but is a useful invariant for screen readers in split-screen /
  zoom contexts — the two announcements stay distinguishable.

Total: 212 web tests passing (was 210).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:55:39 +08:00
551021a2c7 feat(uploads): reject HEIC/HEIF/AVIF; sidebar brand → dashboard link; activity tabs scroll
Three small follow-ups:

1. HEIC/HEIF/AVIF uploads now rejected at the door
   ----------------------------------------------------
   Symptom: an iPhone-shot image uploaded fine but came through on
   WhatsApp without a thumbnail. Bot logs:

       failed to obtain extra info
       heif: Error while loading plugin: Support for this compression
       format has not been built in

   Cause: the bot container's Sharp ships without a HEIF/AVIF
   decoder, so the thumbnail-extraction step Baileys runs throws and
   the message is sent without a preview.

   Fix: the upload validator (`validateForWhatsApp`) now rejects the
   HEIF family before the file ever reaches the action body. Error
   message: "Images are not supported, please re-upload images".

   New tests in `lib/whatsapp-media.test.ts`:
   - `isUnsupportedImageMime` recognises image/heic, image/heif,
     image/heic-sequence, image/avif (case-insensitive).
   - `isUnsupportedImageMime` does NOT flag jpeg/png/webp/gif.
   - `validateForWhatsApp` rejects a HEIC upload regardless of size,
     even below the 5 MB image cap.

2. Desktop sidebar brand is now a link to /
   ----------------------------------------------------
   The mobile header brand pill was already a link to /; the desktop
   sidebar version was a static <div>, so clicking the "cm WhatsApp
   Bot" header in the sidebar did nothing. Wrapped in <Link href="/">
   with `aria-label="Go to dashboard"` and a hover background to
   make the affordance obvious.

3. Activity tab strip switched from full-width to scrollable
   ----------------------------------------------------
   The activity page has six tabs (All / Success / Partial / Failed
   / Skipped / Archived) — packing them into a `w-full` row at h-8
   left every label squeezed to ~50px on mobile. Wrapped the
   <TabsList> in an `overflow-x-auto` scroller (with negative
   horizontal margins so the strip extends to the page edges and the
   first/last tabs aren't clipped) so each tab keeps a comfortable
   touch target on phones; on desktop the row fits naturally and no
   scroll bar appears.

   Reminders page kept its full-width layout — only 4 tabs there,
   they don't crowd.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:54:30 +08:00
f24619e3d6 test(app-shell): cover header + menu drawer + sidebar; full-width status tabs
12 new SSR tests in app-shell.test.tsx:

Mobile header
- Fixed top header is rendered with `sm:hidden` so it disappears on
  the desktop breakpoint.
- Brand mark on the left links home and carries `aria-label="Go home"`.
- Page title in the centre is derived from usePathname:
  * "/"             → "Dashboard"
  * "/accounts/123" → "Accounts" (sub-route falls back to parent label)
  * unknown route   → generic "WhatsApp Bot"
- Menu button on the right is labelled `aria-label="Open menu"`.

Menu drawer (Sheet primitives mocked transparent so SSR shows content)
- Renders one nav link per NAV_ITEM, in declared order.
- The active route's link gets `aria-current="page"`; others don't.
- Dashboard ("/") matches by exact equality, not by `startsWith`, so
  every page doesn't get marked Dashboard.
- The drawer does NOT include the theme toggle — it lives only in
  the desktop sidebar footer per the recent product call.
- Drawer header carries the brand wording and the SR-only nav-menu
  description.

Desktop sidebar
- Renders with `hidden sm:flex` (mobile-hidden, desktop-visible).
- All NAV_ITEMS appear.
- Theme toggle is present in the sidebar footer.

Plus the small follow-up the user pointed at:

UI: status tabs span the full row
- The shadcn `<TabsList>` defaults to `inline-flex w-fit`, which
  packed Active/Ended/Paused into a tight cluster on the left of
  the reminders + activity pages. Added `w-full` to both
  `<TabsList>` invocations so the tabs distribute evenly across
  the available row width (`flex-1` on each `<TabsTrigger>` already
  handles even widths once the parent stretches).

Total: 206 web tests passing (was 194; +12 from app-shell.test.tsx).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:50:54 +08:00
ab547c7b34 fix(reminder-edit): preserve message stack across all section forms; UI cleanup
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>
2026-05-10 12:47:38 +08:00
82b00508f0 feat(uploads): per-kind WhatsApp media size limits, lift Server Action body cap
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>
2026-05-10 12:35:41 +08:00
32319feeea fix(reminders): edit paused/ended one-off; lock list order; collapse swipe after action
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>
2026-05-10 12:29:10 +08:00
8023c8f357 feat: bidirectional swipe — left=Delete, right=Archive/Pause; reminders list too
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>
2026-05-10 12:24:55 +08:00
704bc5e788 feat(activity): swipe-to-archive/delete; quieter send-test toast
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>
2026-05-10 12:20:05 +08:00
b71dbadef1 feat(reminders): multi-message stack with mid-stream media swap
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>
2026-05-10 12:15:37 +08:00
f681be9deb feat: full timestamp on accounts list; Duplicate action on reminder detail
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>
2026-05-10 12:03:41 +08:00
52126765f4 fix(unpair): allow whatsapp_groups delete by relaxing run_targets FK
Symptom
-------
Click Unpair on a connected account (or Delete Account). The web
action runs:

    DELETE FROM whatsapp_groups WHERE account_id = ?

and Postgres rejects it:

    error: update or delete on table "whatsapp_groups" violates
    foreign key constraint
    "reminder_run_targets_group_id_whatsapp_groups_id_fk"
    on table "reminder_run_targets"

Cause
-----
\`reminder_run_targets.group_id\` had a non-null FK to
whatsapp_groups.id with no ON DELETE rule (defaults to NO ACTION /
RESTRICT). So any reminder that had ever fired pinned the group rows
in place. Unpair couldn't wipe the synced groups, the action threw,
and the row never reached \`status='unpaired'\`.

Fix
---
Mirror the pattern \`reminder_runs.reminder_id\` already uses
(migration 0005): nullable column + ON DELETE SET NULL + a
denormalised label snapshot, so historical fan-out records survive a
group wipe but stay readable.

Migration 0006:
- Drop the composite \`(run_id, group_id)\` PK; add a surrogate
  \`id uuid pk default gen_random_uuid()\` since \`group_id\` can no
  longer be part of the PK once it's nullable.
- Make \`group_id\` nullable.
- Re-create the FK with ON DELETE SET NULL.
- Add \`group_label text\` for the snapshot.

fire-reminder.ts now writes the group's name into \`group_label\`
on every insert path (success / failed / skipped /
account-not-connected / group-missing) so the Activity tab can keep
showing "Sent to <Group Name>" even after the group is gone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:01:16 +08:00
731d6d66a6 fix(unpair): stop session manager from racing the web's status write
Symptom
-------
Click "Unpair" on a connected account. The web action sets
\`status='unpaired'\`, but the account detail page often still shows
"Disconnected" — and on accounts that had been previously connected,
the QR pair flow restarts a few seconds later all on its own.

Cause
-----
Two races inside the session manager:

1. The web's \`unpairAccountAction\` notifies the bot via \`pg_notify\`
   and then writes \`status='unpaired'\` to the row. The bot's
   \`handleUnpair\` calls \`sessionManager.stop()\` which closes the
   Baileys socket; Baileys eventually fires a \`connection: close\`
   event which the manager's \`handleEvent\` translates into a
   \`status='disconnected'\` UPDATE. Whichever write lands second wins.
   The user clicks Unpair and sees Disconnected.

2. The same close-event handler schedules a 5-second
   \`stop().then(start())\` reconnect for accounts whose
   \`lastConnectedAt\` is set. Five seconds after unpair, the bot
   silently re-opens the socket, the row flips to \`pending\`, and the
   QR carousel restarts.

Fix
---
\`stop(accountId, { intentional: true })\` marks the account in a new
\`intentionalStops\` Set. When the close event lands, \`handleEvent\`
drains the flag (with \`Set.delete()\` returning whether the key was
present, so it's exactly-once and a stale flag can't bleed into a
later session) and skips both the DB UPDATE and the reconnect
schedule. The caller — only \`handleUnpair\` for now — is the one
choosing the row's next state, so we step out of its way.

The flag is set ONLY when callers ask for it. Internal recoveries
(restartRequired auto re-open, ephemeral-close back-off) keep the
default behaviour and continue to write \`disconnected\` + reschedule.

Drive-bys
---------
- Refresh the stale "the row is gone by the time we run" comment in
  unpair-handler — the row stays alive now (the operator can re-pair
  without retyping the label). Look up the account first so the
  audit log carries the real \`operatorId\` instead of \`null\`. The
  delete-account flow really does delete the row before notifying us;
  the lookup tolerates that and falls back to \`null\`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:39:56 +08:00
7b991a565d feat(ui): equal-width Yes/Close buttons in confirm dialogs; trim dashboard activity to 3
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>
2026-05-10 11:35:38 +08:00
48cae84919 feat(recurrence): Yearly tab — month grid + day grid, both multi-select
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>
2026-05-10 11:28:42 +08:00
b8f60bdaf3 feat(recurrence): Monthly multi-date grid; rename empty state to "No Repeats"
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>
2026-05-10 11:23:02 +08:00
657fa71bf9 feat(recurrence): Daily tab is "every day at <time>" — drop weekday choice
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>
2026-05-10 11:21:41 +08:00
08435988c2 feat(recurrence): per-rule fire time on every tab; drop redundant labels
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>
2026-05-10 11:18:23 +08:00
797917a4ba feat(recurrence): inline picker + multiple recurring schedules per reminder
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>
2026-05-10 11:09:30 +08:00
a7a5c6821b feat(recurrence): redesign as a Temenos-style trigger + dialog picker
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>
2026-05-10 11:02:16 +08:00
b67d3c735e feat(recurrence): replace the long preset list with a guided cron flow
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>
2026-05-10 10:54:10 +08:00
63b88c69b4 feat(recurrence): cron-only Repeats picker
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>
2026-05-10 10:32:29 +08:00
5f1897daa5 feat(recurrence): full crontab support — sec/min/hour/day/month/dow combinational
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>
2026-05-10 10:25:47 +08:00
991ff5fb22 feat(recurrence): redesign Repeats picker as a preset radio list
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>
2026-05-10 10:18:39 +08:00
50df7fcb11 feat(reminders): search + filter + sort on the list, Pause/Restart/Delete on detail
/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>
2026-05-10 10:11:46 +08:00
ec57a78853 feat(send-test): close the loop — bot reports done back to the form
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>
2026-05-10 10:04:33 +08:00
c95b9658d1 fix(bot): treat post-pair "restart required" close as success, not timeout
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>
2026-05-10 09:45:37 +08:00
34f22a4f24 feat(web): show a spinner while the first QR is being generated
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>
2026-05-10 09:39:14 +08:00
1c9cb75111 test: pairing-state transitions + accounts overview shows pending rows
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>
2026-05-10 09:36:45 +08:00
fe135cdef5 fix: don't hide accounts in 'pending' state; park failed pairs as 'unpaired'
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>
2026-05-10 09:34:46 +08:00
8ca7ebdd5b feat(web): drop Delete card from accounts overview
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>
2026-05-10 09:30:11 +08:00
c8199f0bbf fix(web): switch dialog cards to transparent <button> overlay; add test guards
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>
2026-05-10 09:22:30 +08:00
99fd2584e4 fix(web): drop <button>-wrapping-<Card> — div inside button is invalid HTML
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>
2026-05-10 09:11:35 +08:00
2b71ebeb17 test(web): unit tests for ThemeToggle (8 tests)
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>
2026-05-10 09:05:58 +08:00
e6f4e3b2e5 revert(web): restore theme toggle — gcr extension, not next-themes, was the issue
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>
2026-05-10 09:04:27 +08:00
5c3348ef2d fix(web): suppress hydration warning on html/body — browser extensions inject attrs
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>
2026-05-10 09:00:41 +08:00
65f4d2d099 fix(bot): revert qrTimeout — keep Baileys' native 60/20s rotation
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>
2026-05-10 08:58:35 +08:00
234e8aa690 fix(web,bot): drop next-themes, extend QR validity, fix retry CTA
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>
2026-05-10 08:57:13 +08:00