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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Three small bugs the user surfaced after the swipe rollout:
1. Editing a paused/ended one-off reminder threw "Time is in the past"
----------------------------------------------------------------------
The four edit-section pages (account / message / groups / when) all
POST through `updateReminderAction`. The action's "scheduledAt
must be in the future" check fires on every submit — including the
three section pages that don't change the time and just pass the
original `scheduledAt` straight through. So a user editing the
message body of a reminder they paused yesterday saw their save
rejected with "Time is in the past".
New pure helper `validateUpdateScheduledAt` in lib/reminder-update.ts
keeps the future-time check in place for active reminders that are
actually changing the time, but allows past timestamps when:
- the reminder is paused or ended (won't fire while in those
states regardless of what the row says about scheduledAt), OR
- the submitted timestamp matches the existing one within a
second of rounding (the form is a passthrough).
Tests: 10 cases in `lib/reminder-update.test.ts` covering active
future, active past, paused passthrough, ended passthrough, paused
with deliberate change, sub-second drift tolerance, exact-NOW edge,
null existing scheduledAt, malformed ISO.
Also (drive-by, related): `updateReminderAction` no longer force-
sets `status: "active"` on save. Editing a paused reminder's
message shouldn't silently un-pause it. The user uses Restart for
that.
2. Reminder list reshuffled after Pause/Restart
----------------------------------------------------------------------
The list defaulted to `sort=scheduled_desc`, so clicking Restart on
row N (which moves scheduledAt forward to the next occurrence) flipped
the row to row 0. Felt like the wrong action ran. Fixed:
- Page now hard-codes `sort = "created_desc"` (created_at never
changes, so a row stays where it is).
- Sort dropdown removed from `<ReminderFilterBar>` since it has
nothing to drive anymore. Account + Group filters and the
search box stay.
3. Swipe shelf stayed open after the action ran
----------------------------------------------------------------------
`SwipeableRow` keeps its offset in component state. When a shelf
button submits the form, the page revalidates and re-renders, but
React keeps the same row instance (matched by `key={reminder.id}`),
so the open offset stuck around. Now both row sites encode the
"row state" into the key:
- reminders: `key={\`${reminder.id}-${reminder.status}\`}`
- activity: `key={\`${run.id}-${run.archivedAt ? "1" : "0"}\`}`
Status flip → key change → React unmounts/remounts → offset back
to 0 → shelf closed. Costs nothing (these rows are cheap).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two follow-ups on the activity-row swipe work:
1. SwipeableRow now supports BOTH directions
----------------------------------------
The component grew a `leftActions` slot alongside the existing
right shelf. Drag the row right to pull the left shelf into view
(non-destructive action: Archive, Pause, etc.); drag left to pull
the right shelf into view (destructive: Delete). Past
REVEAL_THRESHOLD (60 px) the corresponding shelf locks open;
below it, snaps closed. Each shelf is opt-in — omit a slot and
the row only swipes one direction.
- `computeSwipeNext` and the new `snapPosition` helper take a
`{ leftActions, rightActions }` flag pair so the math knows
which directions are valid. Drags toward a missing shelf get
clamped to 0 instead of fully open.
- Activity rows wired as iOS-Mail-style: leading edge (right
swipe) = Archive/Restore (amber), trailing edge (left swipe)
= Delete (destructive red).
- Tests grew to 16 cases covering: snap-to-closed below threshold
either way, snap-to-open at/past threshold either way, clamps
don't escape the shelf width, missing-shelf rows don't snap to
a non-existent open state, baseOffset-aware reverse-drag math,
and SSR markup contracts (data-testid, data-state="closed",
translateX(0px), aria-hidden=true on closed shelves, no
orphaned shelf wrapper when only one slot is provided).
Also fixed a `-0` slip in the clamp branch (`-maxRight` is `-0`
when maxRight is 0) so call-site equality checks behave.
2. Reminders list rows are swipeable too
----------------------------------------
/reminders page now wraps each row in SwipeableRow:
- Left swipe → Delete (always available, destructive).
- Right swipe → Pause (when status is "active") OR Restart
(when "paused" or "ended"). Other lifecycle states (failed)
omit the right shelf entirely; the row only swipes one way.
Each shelf button is a tiny `<form>` posting to the existing
server action (delete / pause / restart) — no client-side state
beyond the swipe gesture. Page revalidates after the action,
list re-renders, row redraws in its new state.
Reused the same shelf-button visual language as the activity
tab (color-coded action, icon + label, dark-mode pairs) via a
tiny inline `ReminderShelfButton` helper.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
/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>
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>
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>