Twice now we've shipped a deploy that 500'd in production because
drizzle silently skipped freshly-generated migrations whose `when`
timestamps were older than a prior manually-bumped entry (0010/0011
in 1b7f553, then 0012/0013 in 2731888). Both times pnpm migrate
printed "Migrations applied." while the live DB schema lagged the
code's expectations.
Three layers of defence:
1. packages/db/src/journal-check.ts — pure helpers
- assertJournalMonotonic(entries): walks idx-sorted entries and
returns each one whose `when` <= the previous entry's `when`,
plus a suggested `when` value to bump it to.
- formatJournalViolations(result): renders an actionable
multi-line message that points at the offending file path.
2. packages/db/src/migrate.ts — pre-flight
Reads _journal.json BEFORE handing it to drizzle.migrate(). If
the journal is non-monotonic, it prints the violations + bump
instructions and exits with code 2. No more "Migrations applied."
while silently skipping.
3. apps/web/src/test/drizzle-journal-monotonic.test.ts — CI guard
Reads the committed _journal.json at test time. CI fails on the
PR before the bad commit can ship. Imports the helper through a
new "./journal-check" subpath export on @cmbot/db so the test
doesn't rely on a deep path into the package.
Together: a bad commit fails CI; if it somehow got through, migrate
itself refuses to run; if migrate is bypassed, the previous deploy's
schema stays intact (drizzle wouldn't have skipped anything in any
case where the journal is monotonic).
Web suite 480 → 482 tests, all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related bugs from the same review pass:
1. /settings/users lit up BOTH the Admin and Settings entries in the
sidebar/drawer. The active-state check was naïve
`pathname.startsWith(href)`, which matches every parent prefix.
Replaced with a longest-match helper pickActiveNavKey() in
nav-config.ts: the most-specific item wins, parents stay quiet,
'/' only matches an exact pathname, and a strict-descendant check
(`href + '/'`) prevents `/settingsfoo` from lighting up Settings.
2. <DialogFooter showCloseButton> on the user-row delete (and three
other dialogs that I missed earlier) was rendering an extra outline
"Close" button next to the operator's own Cancel + Radix's corner X.
Stripped the prop from every remaining caller (login, dashboard
clear-history, reminder actions-bar, settings/users delete) so each
dialog footer shows just Cancel + the primary action.
Tests:
- nav-config.test.ts: 7 new cases covering the longest-match contract
— /settings/users highlights ONLY Admin, /settings highlights ONLY
Settings, '/' is exact-match only, sibling-prefix /settingsfoo
matches nothing, and a defense-in-depth probe asserts at-most-one
nav highlight across a representative pathname set.
- test/no-dialog-footer-show-close-button.test.ts: static guard that
grep-walks every production .tsx and fails if anything passes
`showCloseButton` to <DialogFooter>. Mirrors the existing
no-button-wrapping-card guard so the prop can't sneak back in.
Also self-checks the regex (matches single-line + multi-line +
other-prop combos; ignores clean DialogFooter and same-named props
on unrelated components).
463 → 477 web tests, all green; typecheck clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>
Account-level destructive actions (Delete, Unpair, Re-pair) live on
the detail page only. The overview is now a calm grid of one card per
account, each linking to its detail page.
- Removed the dedicated Delete card and its dialog from
accounts-list-view.tsx.
- The whole account card is once again the link target — no inline
trigger surfaces, no Dialog component, no destructive click area.
- AccountsListView no longer needs the deleteFormAction prop; the
/accounts page passes only `accounts`.
Tests updated:
- accounts-list-view.test.tsx: 6 tests now (was 8). The two cases that
asserted on the delete card are replaced with one positive test that
asserts no Delete affordance is rendered on the overview, plus a
test that the only `<a>` per cell wraps the card with no inline
buttons inside it.
- no-render-warnings.test.tsx: drops the obsolete deleteFormAction
prop in its renderQuiet calls.
Hydration: live curl on /, /accounts, /reminders, /activity,
/settings and a detail page returns 200 with no Hydration / script-tag
warning in the web logs after this commit.
98 tests passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The remaining "Hydration failed" error came from passing a Card (a <div>)
as the asChild target of Radix's DialogTrigger. Radix's Slot then
injects button-specific props (type="button", aria-haspopup, …) onto
the underlying <div>, and React's SSR vs client trees diverge on those
attributes.
Same overlay pattern that already worked for the Pair card now applies
to every Dialog-card-trigger in the app:
- accounts list — Delete card per row
- account detail — Unpair card
- account detail — Delete card
The visible Card stays a <div>. A real <button type="button"> with no
children sits absolutely-positioned over the card surface and is the
DialogTrigger target. Click area is identical, HTML is valid, no Radix
prop-forwarding into the wrong element type.
Also fixed: edit-account-form.tsx had the original
<button>...<Card>...</Card></button>
nesting (the new static guard caught it). Replaced with a Card that's
its own pressable region (onClick + onKeyDown + role=button on the
<div>; no nested button).
Test guards
-----------
+ src/test/no-render-warnings.test.tsx (6 tests)
Renders AccountsListView, ThemeToggle, EditMessageForm via
renderToString and asserts neither console.error nor console.warn
was invoked. Also scans the produced HTML for any <button> region
that contains a <div>/<p>/<h*> — invalid nesting that would cause
a hydration mismatch in the browser.
+ src/test/no-button-wrapping-card.test.ts (2 tests)
Walks every production .tsx file in src/ and fails if any contains
a literal `<button` (lowercase) that wraps `<Card`/`<CardContent`/
`<CardHeader`. Caught a real instance in edit-account-form.tsx that
I missed in the earlier round.
Total tests: 100.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>