5 Commits

Author SHA1 Message Date
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
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
6cb387bf59 feat: per-section reminder edit, activity tab, more tests
Per-section reminder editing
- Replace the wizard-redirect edit shell with four focused single-form
  pages: /reminders/[id]/edit/{account,message,when,groups}.
- Each click on a section card on the detail page goes straight to the
  matching focused editor — no stepper, no other sections, no
  wizard chrome. Save returns to the detail page.
- New form components live under components/reminder-edit/:
  EditMessageForm, EditWhenForm (full recurrence builder reused),
  EditGroupsForm, EditAccountForm. All submit via updateReminderAction
  with the existing values for untouched fields. Switching account
  clears group targets (groups are scoped per account; the form warns
  and the user re-picks groups afterwards).

Activity tab
- New "Activity" item in the bottom nav + sidebar (between Reminders
  and Settings).
- /activity page: full run history (last 200), filter tabs (All /
  Success / Partial / Failed / Skipped), clickable rows that open the
  underlying reminder, and a Clear history dialog. Mirrors the
  dashboard's Recent Activity widget but with deeper data and its own
  empty-state messaging.

Tests (+20 — 80 passing total)
- qr-dedupe.test.ts: 14 tests covering the makeQrDedupe factory (per-
  account, fresh QRs always emit, reset/scope) and countdownRender
  (the QR-expired timer logic — danger threshold, expired flag,
  clamping). The dedupe + countdown logic is now used by pair-handler
  and pair-live.
- reminder-edit/edit-message-form.test.tsx: 6 tests verifying the form
  pre-fills, hides/shows the caption based on attachment, renders the
  Save (not "Schedule reminder") action, and the action receives the
  expected payload shape for both text-only and media-attached paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 01:35:17 +08:00