22 Commits

Author SHA1 Message Date
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
bf49b80431 feat(web): pause-by-hour deadline + AM/PM dropdowns + dashboard tweaks
Wizard When-step and the per-section Edit When page now expose an
optional "Pause sending by" hour. Fire time IS the implicit start, so
the deadline is the only thing the operator sets. When the bot's
fan-out hasn't finished by that hour (in the reminder's timezone) the
run pauses for resume — that runtime gating lands in a later phase;
this commit just persists the hour and threads it through the wizard.

HourSelect splits hour and AM/PM into two side-by-side <select>s and
emits a single 0..23 value. to12Hour / from12Hour are pure helpers
covered by 11 round-trip tests.

Dashboard adjustments:
* "WhatsApp accounts" card now reads Connected / Unpaired / Total.
* "Reminders" card reads Active / Paused / Ended / Total.
* "Recent runs" stat card removed (the Recent activity section below
  shows the same info).
* Activity rows show absolute timestamp with AM/PM and relative time
  in tandem.

Accounts list:
* The page-level <h1>Accounts</h1> is hidden on mobile (the top bar
  already shows it), matching the Dashboard pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:07:25 +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
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
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
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
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
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
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
86f2fe0124 fix(web): reminder wizard date/time picker, reorder, optional groups
- Fix "Invalid datetime" error: createReminderAction's Zod schema rejected
  offset-suffixed ISO strings (luxon's `toISO()` produces +08:00 form).
  Switched to `.datetime({ offset: true })`.

- Replace the single datetime-local input with separate native date + time
  inputs (proper UI pickers on both desktop and mobile). Default value is
  now computed server-side ("now + 1h") and passed in as a prop, so first
  render is fully populated and there's no SSR/client hydration mismatch
  from `Date.now()` inside the client component. Removed the quick-pick
  shortcuts.

- Reorder wizard steps: Account → Compose → When → Groups → Review.
  Groups is now the last and optional step (Continue button reads
  "Skip groups" when empty); the action accepts an empty array and
  inserts no reminder_targets in that case.

- Account list: card is the link target. Removed inline Pair / Open /
  Delete quick-action buttons; lifecycle actions stay on the detail page.

- Account detail: removed the "Sync Groups Now" card. The bot already
  auto-syncs on `groups.upsert` / `groups.update` events. The Groups card
  itself is now a clickable link instead of carrying an inline View
  button.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 00:45:19 +08:00
9437df74ee feat(web): split Add Account from Pair; add Unpair/Re-pair/Delete actions
Reshape the account lifecycle to match how operators actually want to
work the system:

- Add Account → creates a row with status='unpaired'. No QR yet; the
  operator lands on the detail page.
- Pair / Re-pair → transitions an unpaired account to status='pending'
  and opens the live QR flow. Works for first-time pair AND for re-pair
  of an account that was previously unpaired.
- Unpair → asks the bot to stop the live Baileys session and clean
  session files; sets status='unpaired' but KEEPS the row (and its
  reminders) so the operator can re-pair without retyping anything.
- Delete → permanently removes the account and cascades to its groups,
  reminders, run history.

Schema:
- whatsapp_groups.account_id and reminders.account_id now have
  ON DELETE CASCADE so deleting an account fans out cleanly.

UI:
- /accounts list shows everything except the transient 'pending' state.
- /accounts/[id] shows state-aware buttons: Pair (when unpaired/banned/
  disconnected), Sync + Unpair (when connected), Delete (always).
- /accounts/new is now an "Add Account" form (label only).

Other fixes:
- next.config.ts: allowedDevOrigins includes 192.168.0.253 +
  test/rexwa subdomains so Server Actions work across the LAN.
- packages/shared/src/rrule.ts: rrule@2.8.1 has no exports field and
  ships ESM that some bundlers can't resolve via default OR named
  import. Use createRequire to bridge — works under both NodeNext
  (bot runtime) and Turbopack (web SSR).
2026-05-10 00:27:33 +08:00
6916f5a0ed feat(web): delete reminder server action wired to detail page 2026-05-09 23:46:23 +08:00
8fd5468e3a feat(web): reminders list + detail pages with run history 2026-05-09 23:36:18 +08:00