18 Commits

Author SHA1 Message Date
f4da1dd510 fix(web): halve right-meta column cap (max-w 20% / 5.5rem)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:16:40 +08:00
50b7e61037 fix(web): tighter cap on right-meta column (max-w 40% / 11rem)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:15:18 +08:00
89c7b1a84d fix(web): cap right-meta column on reminders list so name doesn't get starved
The recurrence summary ("Every month on days 4, 6, 7, 11, 13, 14 +6
more at 11:32") rendered without truncation in the right meta column,
which had `shrink-0` + no max-width — so the column expanded to fit
the text and the reminder name on the left was forced to truncate
aggressively or wrap.

Cap the right column at max-w-[55%] on mobile / sm:max-w-[14rem] on
desktop, add min-w-0 to each row inside, and truncate every meta
span. Long recurrences now ellipsis with a hover title tooltip; the
reminder name reclaims the breathing room it should have.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:13:49 +08:00
be3f28a1e6 refactor(web,bot,db): rename reminder status 'ended' → 'inactive'
The 'ended' label read like a terminal failure state ("the reminder
gave up") when in practice it just means "this reminder isn't going
to fire on its own — restart it if you want it back". 'inactive' is
the more accurate read.

* SQL migration 0009 backfills existing rows.
* Bot fire-reminder writes 'inactive' on one-off completion / no
  further occurrences.
* Web actions, queries, filters, and reminder lifecycle gates updated.
* Dashboard counter card label "Active / Paused / Ended / Total"
  becomes "Active / Paused / Inactive / Total".
* Reminders list filter tab "Ended" becomes "Inactive".
* Status pill style key renamed to match.
* Tests updated alongside the runtime changes.

Also: the "Pause sending by" deadline opt-in now renders as a
visible card-shaped row with hover state + Set/Off label on the
right, so the toggle is discoverable instead of a tiny native
checkbox tucked next to the label.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:32:53 +08:00
1d0d06d648 feat(web): floating CTA on mobile so Add/New buttons aren't a wasted row
Adds a `floatingAction` slot to PageShell. Desktop renders it inline
next to the H1 (same as before); mobile drops the entire header row
and floats the action as a fixed pill in the bottom-right corner —
the page now starts straight at content with no wasted vertical
space at the top when only an action exists.

Add Account / New Reminder buttons grow to size-12 circles on mobile
(easy thumb target) and keep the compact h-7 inline pill on desktop.
The action node is rendered twice in the tree — once inline, once
fixed — and switched via responsive utilities.

Bumps mobile bottom padding to pb-20 when a FAB is present so the
last card doesn't sit under the floating button.

Activity's "Clear history" still uses the regular `action` slot — it
keeps the inline header row on mobile because it isn't the page's
primary CTA.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:27:32 +08:00
9b13223966 fix(web): tighten the page-header row — smaller buttons, less padding
Action buttons drop from size=lg with px-6 + font-semibold to a
compact size=sm pill with a subtle shadow. PageShell trims mobile
top padding from 24px to 16px and the inter-section gap from 24px
to 16px on small screens (desktop unchanged) so the header row
doesn't dominate the top of the page when the H1 is hidden.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:25:08 +08:00
3c3a3f57d3 refactor(web): extract EmptyState and reuse on every empty surface
One component now owns the icon / heading / helper / action stack
that the dashboard, accounts list, reminders list, and activity tab
were each rendering inline. The four duplicated 'flex-col items-center
py-12 text-center' Card blocks collapse to one shared surface so the
empty experience reads the same wherever the user lands.

Covered by 4 SSR tests (icon + title + description, omitted helper,
action slot pass-through, centring).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:21:51 +08:00
a5cb8cea46 refactor(web): extract PageShell and apply to every tab
Single component now owns the page chrome — wrapper width, padding,
vertical rhythm, and the page-header row (hidden-on-mobile H1 + an
optional right-aligned action slot). Dashboard, Accounts, Reminders,
Activity, and Settings all use it, replacing five copies of the same
\`<div className=\"max-w-5xl mx-auto px-4 ...\">\` markup.

Settings was previously \`max-w-2xl\` and \`container mx-auto\`; it
now matches the other tabs at 5xl so the chrome stays consistent.

Covered by 5 SSR tests (header order, responsive justify utilities,
wrapper class, action-optional path).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:19:17 +08:00
e6521bd151 feat(web): run-eta helper + bigger pill-shaped add buttons
estimateRunDuration() computes a per-run ETA from a target count, a
fire time, and an assumed per-account send rate (40/min, mirroring
the bot env). Adds a 15% buffer with a 1-minute floor. Pure helper,
covered by 6 round-trip tests including the rate-defaults path.

Header CTA buttons on /accounts and /reminders are now size="lg"
rounded-full pills with a shadow that lifts on hover. Mobile shows
just the plus icon (label collapses) so the button doesn't dominate
narrow screens.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:10:09 +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
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
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
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
7b4f0d0b84 fix(web): pairing-window timer, reminder filter tabs
QR / pairing
- Replace the per-QR 30 s countdown with a single pairing-window timer
  matching the bot's PAIR_TIMEOUT (5 minutes). Baileys naturally rotates
  QR images every ~5 s — the previous 30 s bar reset on every rotation,
  which felt like a constantly-cycling timer to the user.
- The new timer starts on the first QR and ticks down once; later QR
  rotations refresh the displayed image but leave the countdown alone.
- Added a hint: "The QR rotates automatically every few seconds — scan
  whichever one is showing." Format switches to MM:SS.
- countdownRender's danger threshold scales: 10 s for short windows
  (≤ 60 s), 30 s for the multi-minute pairing window, so the warning
  flash appears while the user can still react.

Reminder filter tabs
- Tabs are now: All / Active / Ended / Paused. "Failed" is dropped —
  reminder.status doesn't carry "failed" (run statuses do; that view
  belongs in /activity?filter=failed).

Tests (+4 = 84 passing total)
- qr-dedupe.test.ts: extended with a "pairing-window scaling" suite
  covering pct/danger/expired at 5-minute scale and the threshold split
  between short and long windows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 08:36:26 +08:00
f19ea03e0d feat: edit reminders, mature recurrence, QR throttle, more tests
Reminders
- Reminder list / detail show recurrence summary ("Every Mon, Wed, Fri",
  "Every 2 weeks until 2027-01-01", etc).
- Detail page reorganised: each section (Account / Message / When /
  Groups) is itself a clickable card that deep-links into the wizard
  step in edit mode (editReminderId URL param). No standalone Edit
  button. Run history stays read-only.
- New /reminders/[id]/edit shell loads the row, encodes its state into
  wizard URL params, and forwards to /reminders/new. The wizard
  threads editReminderId through every step.
- updateReminderAction: validates ownership of both the existing
  reminder and the (possibly changed) target account, replaces targets
  + messages wholesale, re-arms the pg-boss job (singleton key picks
  up the new fire time).
- Wizard submit branches to updateReminderAction when editReminderId
  is set; button reads "Save changes" / "Saving…".
- Wizard default first-fire is now the current minute in the operator
  zone (not now+1h). Same-minute clicks bump silently to next minute
  via a 60 s grace window so the user isn't punished.
- /reminders empty state is filter-aware: "No failed reminders yet."
  when ?filter=failed and there are reminders in other states.

Recurrence
- Spec is now a structured object: { kind, interval, weeklyDays,
  monthDay, end }. Builder produces RRULEs with INTERVAL, BYDAY,
  BYMONTHDAY, COUNT, UNTIL as appropriate. specFromRrule round-trips
  for resuming/edit.
- When-step UI: frequency pills, "Every N days/weeks/…" interval,
  weekday picker (weekly), day-of-month input (monthly), end picker
  (Never / After N occurrences / On date), live human-readable
  summary preview.

QR pairing
- Throttle QR refresh to once per 25 s and detach the previous
  per-account session listener on Re-pair so listeners can't
  accumulate. The UI countdown was flicking every ~5 s because each
  Re-pair attached an extra listener — every Baileys QR event then
  triggered a fresh DB write + NOTIFY.

Tests (60 green total, +33 in this batch)
- recurrence.test.ts: extended to 25 tests covering interval,
  monthday, end conditions (COUNT/UNTIL), and round-trip parsing.
- date-picker.test.ts: 14 tests for splitDateTime / combineDateTime /
  validateScheduledAt (incl. the "click-too-fast" same-minute grace)
  and defaultFirstFireIso.
- /api/qr/[accountId] route.test.ts: 4 tests — 404 when no QR yet,
  404 on missing row, 200 with image/png + no-store + correct PNG
  bytes, and verifies the where-clause queries by accountId.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 01:22:22 +08:00
8fd5468e3a feat(web): reminders list + detail pages with run history 2026-05-09 23:36:18 +08:00