docs(planning): record phase D multi-agent QA + reconcile

Phase D wrap-up:
- 5 parallel reviewer findings recorded
  (code-review / security / design / senior-dev / simplify)
- reconcile report at `.planning/phase-D-reconcile-report.md`
- v1.4.16 backlog written at `.planning/v1416-backlog.md`
  (8 deferred HIGH + 39 MED/LOW + 4 simplify-no + 3 process items)
- STATE.md phase-D status block updated

0 CRITICAL across all reviewers. 5 HIGH fixed in commits cd3b890 /
6465412 / 66d2e07 / 79bb167. 8 HIGH deferred to v1.4.16 (admin-only
leaks, charts visual constraint, cross-cutting design-system bump,
large-file refactors, multi-account hardening, process change).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-09 22:20:34 +02:00
1 parent d94756389f
commit 1b1214bcb3
8 files changed
+2112 -8

No files matched your search

+43 -8
View File
@@ -861,15 +861,50 @@ notifications, messages mass refactor, correlation-card inline empty
surfaces inside /insights (already use a tighter custom variant —
refactor would be churn-only, deferred to v1.4.16).
## Phase D — Multi-agent QA (parallel, write-only)
## Phase D — Multi-agent QA (parallel)
- [ ] code-reviewer
- [ ] security review
- [ ] design / UX review
- [ ] senior-dev review (architecture/maintainability — separate from code-review)
- [ ] simplify on changed file set
- [ ] reconcile + apply CRITICAL/HIGH inline
- Detailed report: `.planning/phase-D-report.md`
- [x] code-reviewer (4 HIGH, 12 MED/LOW — `phase-D-code-review-findings.md`)
- [x] security review (0 HIGH, 8 MED/LOW — `phase-D-security-findings.md`)
- [x] design / UX review (5 HIGH, 11 MED/LOW — `phase-D-design-findings.md`)
- [x] senior-dev review (4 HIGH, 8 MED/LOW — `phase-D-senior-dev-findings.md`)
- [x] simplify on changed file set (6 yes, 4 no — `phase-D-simplify-findings.md`)
- [x] reconcile + apply CRITICAL/HIGH inline (`phase-D-reconcile-report.md`)
- Detailed report: `.planning/phase-D-reconcile-report.md`
#### Phase D status block — 2026-05-09T22:25+02:00 — done
5 reviewers + reconcile complete. 0 CRITICAL across all reviewers.
Triage:
- **Simplify-yes**: 6 / 6 applied, 0 reverted (commit `cd3b890`).
- **HIGH fixed** (5): design H1/H2/H3 tour focus-trap + DE-overflow +
backdrop ring (`6465412`), design H5 Withings error wrap
(`66d2e07`), senior-dev H4 MockAIProvider strict-schema default
(`79bb167`).
- **HIGH deferred** (8): code-review H1-H4 (admin-only leaks /
edge-cases / charts visual constraint / multi-account only),
design H4 (cross-cutting `button.tsx` 44px sweep), senior-dev H1-H3
(1031 LOC `page.tsx` split, 883 LOC `integrations-section.tsx`
split, worktree adoption process). All in `v1416-backlog.md`.
Tests: 1047 / 1047 unit pass. Typecheck: 3 pre-existing errors in
`dashboard-layout.test.ts` (A4 owns; reproduces on clean stash).
Lint: 0 errors, 11 pre-existing warnings. Format: touched files
clean; pre-existing 41-file format drift across the wider tree
flagged for v1.4.16.
5 commits on `origin/main`:
- `cd3b890` simplify-review safe suggestions
- `6465412` tour focus-trap + DE-overflow + backdrop ring (a11y)
- `66d2e07` Withings error string break-words (a11y)
- `79bb167` MockAIProvider DEFAULT_RESPONSE strict schema
- `d947563` prettier sweep on touched files
Backlog written: `.planning/v1416-backlog.md` —
8 deferred HIGH + 39 MED/LOW + 4 simplify-no + 3 process items.
Most impactful pre-v1.4.16 fast-track: M4 dispatcher legacy
Telegram migration (silently unwinds B3 hard-rejects) and M6
senior-dev AI strict-schema route migration.
## Phase E — Release v1.4.15
+150
View File
@@ -0,0 +1,150 @@
# Phase D — Code-review findings (v1.4.15)
Reviewer: senior code-review (parallel with security / design / senior-dev / simplify).
Scope: diff `v1.4.14...HEAD` (178 files, +20 544 / −1 408). Phases A1–A5, B-mobile, B1–B6, C1–C5.
Method: targeted file reads of the hottest paths called out in the agent brief — AI hardening (C1), backup completeness (B1), sync robustness (B2), notification reliability (B3), onboarding tour (B5), dashboard analytics fixes (A4), auto-deploy webhook (C2), admin overview (A2). No source edits, no commits.
Verdict: implementation quality is high overall. Auth gates are consistently in place on every new admin endpoint. Idempotency/audit/rate-limit patterns are reused properly. The two ship-blocker concerns I flag are HIGH (not CRITICAL) and both are correctness-bugs that surface only on niche inputs.
Counts:
- CRITICAL: 0
- HIGH: 4
- MEDIUM: 7
- LOW: 5
---
## CRITICAL
(none)
The areas with the largest blast radius — `requireAdmin()` in every backup route, timing-safe webhook secret compare, encrypted credential storage, withIdempotency wrapping the destructive restore endpoint, AES-256-GCM at-rest encryption of `IntegrationStatus.lastError` — are all in place and correct. Restore is wrapped in a single Prisma transaction with `skipDuplicates` for idempotent re-runs. Pre-validation of enum values runs OUTSIDE the transaction so a malformed payload cannot half-wipe a user.
---
## HIGH
### H1 — Restore transaction does delete-then-recreate without snapshot rollback safety
- **File**: `/Users/marc/Projects/HealthLog/src/app/api/admin/backups/[id]/restore/route.ts:253-377`
- **Issue**: The Prisma transaction deletes the user's existing data first (intake events, medications, measurements, mood, channels, push subs, telegram scheduled deletions), then re-creates from the payload. If a `prisma.medication.create({...schedules:{create:[...]}})` fails mid-loop on row 5 of 10, the **entire transaction rolls back** — but only the rows still inside `tx`. Prisma's interactive transactions DO atomically roll back on a thrown error inside the callback, so this is in fact safe. However, the catch block on line 378 surfaces `err.message` directly to the admin via `apiError(...)` without scrubbing — if the error embeds a stack trace from Prisma it leaks internal table/column names to the response. Low-severity infosec but more importantly it returns 500 with a misleading "Restore transaction failed: <prisma error>" message.
- **Recommendation**: Verify Prisma rolls back `deleteMany` calls inside the same `$transaction(async tx => ...)` callback (it does — confirmed in Prisma 5/6/7 docs). Then either (a) swallow the inner message and return a stable "Restore failed" 500 plus the audit trail, or (b) wrap the inner message via a pre-existing scrubber (`safeError()` or similar). The audit row already captures the verbose error — admins can read it from `/admin/login-overview` instead.
- **Ship-blocker**: no — Prisma rollback is correct. The leak is minor (admin-only endpoint, error messages on internal table names). v1.4.16 fix.
### H2 — Restore mood-entry recreation discards pre-existing `MoodEntry` IDs and `tags` JSON shape mismatch is silent
- **File**: `/Users/marc/Projects/HealthLog/src/app/api/admin/backups/[id]/restore/route.ts:353-366`, `/Users/marc/Projects/HealthLog/src/lib/validations/backup.ts:73-81`
- **Issue**: `moodEntrySchema.tags` is typed as `z.string().nullable().optional()` — i.e. ANY string. `syncMoodLogEntries()` writes `tags: entry.tags ? JSON.stringify(entry.tags) : null` (sync.ts:221) so the on-disk shape is a JSON-encoded array. The restore route writes the value verbatim with `tags: e.tags ?? null`. If a future moodLog payload writes a non-JSON string (or a future schema migration changes the shape), restore silently loads the bad value. The download path validates against `parseBackupPayload` so we'd catch it, but only if the value wasn't a string at all. A garbage string slips through.
- **Recommendation**: Tighten `moodEntrySchema.tags` to `z.union([z.null(), z.string().refine(s => { try { return Array.isArray(JSON.parse(s)); } catch { return false; } })])` OR keep it permissive on the restore side but validate via JSON-parse during the existing pre-tx loop (alongside `MEASUREMENT_TYPES`/`INTAKE_SOURCES` checks at lines 204-249). Cheaper than a schema change.
- **Ship-blocker**: no — affects only round-trip uploads of corrupted backups, not the normal worker→download→re-upload→restore flow. v1.4.16 backlog.
### H3 — `MoodChart.aggregateMoodEntries` and the inline `chartData` aggregation produce divergent buckets
- **File**: `/Users/marc/Projects/HealthLog/src/components/charts/mood-chart.tsx:185-219` (export `aggregateMoodEntries`) vs lines 247-292 (inline `chartData` useMemo)
- **Issue**: The exported helper buckets entries by `pickMoodBucket(entries)` BEFORE applying the `rangePoints` slice. The inline render path slices to last-N, then computes range-days from the sliced timestamps and runs `pickBucket(rangeDays)` again. Result: a 365-day-window user with `rangePoints=30` sees DAILY buckets in the chart (30 sliced days < 90 threshold) while the unit test exercising `aggregateMoodEntries` over the full dataset reports MONTHLY. The unit-test assertion does NOT cover what the user actually sees on the dashboard. The `activeBucket` chip in the header (lines 346-357) duplicates the inline path's logic so the chip stays consistent with the chart data — but the standalone export is a misleading second source of truth.
- **Recommendation**: Either (a) inline `aggregateMoodEntries()` is dead code only used by tests — drop it and add a test that mounts the actual component or directly asserts the chip's `activeBucket` value; or (b) refactor `chartData` to call `aggregateMoodEntries(sliced)` so both paths share one bucketing primitive. Today the divergence means the unit test is asserting an aggregation behaviour the user never sees.
- **Ship-blocker**: no — the user-visible chart renders correctly. But the test coverage is illusory and a future regression in `chartData` won't fail any test.
### H4 — `TourLauncher` missing-key cleanup on logout / impersonation switch
- **File**: `/Users/marc/Projects/HealthLog/src/components/onboarding/tour-launcher.tsx:138-170`
- **Issue**: The `decidedFor` guard correctly prevents re-deciding for the same `(userId, flag)` tuple. But `sessionStorage` keys (`healthlog-tour-session-dismissed`, `healthlog-tour-referrer`) are NOT scoped by user id. If admin A logs out and admin B logs in inside the same browser tab (no full reload), B inherits A's session-dismiss flag and the tour will not auto-launch for B even though B has `onboardingTourCompleted=false`. The `decidedFor` would re-evaluate (different userId), set `showTour=false` because `readSessionDismissed()` returns true. B has to navigate to Settings → Account → Restart to see the tour at all.
- **Recommendation**: Either (a) clear `SESSION_DISMISS_KEY` on auth logout in `useAuth().logout()`, or (b) namespace the key by user id (`healthlog-tour-session-dismissed:${userId}`).
- **Ship-blocker**: no — affects only multi-account / impersonation flows. Single-user installations (the actual production today) are unaffected. v1.4.16.
---
## MEDIUM
### M1 — `generateInsight()` retry-once injects correction into `userPrompt`, not `systemPrompt`
- **File**: `/Users/marc/Projects/HealthLog/src/lib/ai/generate-insight.ts:155-158`
- **Issue**: The retry concatenates `correction` onto `params.userPrompt`. The wrapper docstring (line 24) says it prepends a "corrective system message" but the code appends to the user prompt. Functionally fine for Codex (the model treats either location as instructions), but breaks the spec the docstring claims. Also: the corrective text on line 56-69 says "You MUST return JSON ... Required top-level fields: summary, recommendations, citations, warnings" — this duplicates the system prompt's schema. If the system prompt evolves and the retry-correction strings diverge, the retry will instruct the model to return a stale schema.
- **Recommendation**: Either (a) update the docstring to say "appended to user prompt" and stop describing it as a system message, or (b) actually pass it through `systemPrompt` (cleaner) and reduce the corrective body to JUST the violated zod issues — let the system prompt own the schema text. The latter is the right long-term move because the schema is already exported via `aiInsightResponseSchema`.
### M2 — `CodexClient.lastDiagnostics` race: instance shared across requests
- **File**: `/Users/marc/Projects/HealthLog/src/lib/ai/codex-client.ts:116, 174-177, 233-236, 242-246`
- **Issue**: `lastDiagnostics` is a per-instance field, mutated synchronously around the SSE consume. `resolveProvider()` is hinted to construct a fresh client per request (codex-slug-cache.ts:17 docstring) but if any caller caches the client (e.g. via module-level memoisation in v1.4.16) the diagnostics reads at the route layer race against a parallel `generateCompletion()` call. Today the route reads diagnostics synchronously after the await resolves so this is safe. Document the contract on `getLastDiagnostics()` so a future refactor doesn't regress.
- **Recommendation**: Either (a) make `generateCompletion()` return diagnostics in the result envelope (cleaner, type-safe), or (b) add a JSDoc `// NOTE: not safe for concurrent calls on the same instance.` on `getLastDiagnostics()`.
### M3 — `recordChannelTransientFailure()` two-write race
- **File**: `/Users/marc/Projects/HealthLog/src/lib/notifications/channel-state.ts:90-130`
- **Issue**: First update increments `consecutiveFailures` and reads back the new value; second update writes `nextRetryAt` based on that value. Between the two, a parallel dispatcher tick could increment again, so the persisted `nextRetryAt` may correspond to a counter that's already stale. The auto-disable branch (line 102) is also non-atomic — between the read and the second update the counter could cross the threshold and another invocation could fire `give_up_after_5_failures` twice. Today the dispatcher only iterates one channel per call sequentially so the race window only opens with multiple concurrent reminders — possible if multiple medications trigger at the same Berlin minute.
- **Recommendation**: Move both writes into a single `prisma.$transaction` so the increment + retry-stamp + audit-log are atomic, OR collapse them by computing `nextRetryAt` client-side based on the response of the first update (which IS atomic per row).
### M4 — `dispatchNotification()` legacy Telegram migration upserts `enabled: true` on every send
- **File**: `/Users/marc/Projects/HealthLog/src/lib/notifications/dispatcher.ts:75-97`
- **Issue**: When a user has legacy `User.telegramEnabled=true` AND a `NotificationChannel(type=TELEGRAM, enabled=false, disabledReason=...)` row exists (e.g. auto-disabled by B3 hard-reject), the migration upsert `update: { enabled: true, config: channelConfig }` will RE-ENABLE the auto-disabled channel on every dispatch. This effectively reverts the B3 hard-reject behaviour for any user still on legacy Telegram config.
- **Recommendation**: Add an `else if (channels.find(c => c.type === "TELEGRAM"))` guard so the migration only runs when no row exists at all. The current `hasTelegramChannel` boolean catches the not-yet-migrated case but doesn't handle the rare "row exists but disabled" path. Alternatively check `update: { config: channelConfig }` (don't touch `enabled`) so a previously-disabled row stays disabled.
### M5 — `aggregateMedicationCompliance` clamps to `Math.min(100, ...)` but can still produce 0 % from imports with `taken=0`
- **File**: `/Users/marc/Projects/HealthLog/src/components/charts/medication-compliance-chart.tsx:75-92`
- **Issue**: The function filters `p.scheduled > 0` but renders `0 %` for any day with `scheduled > 0 && taken === 0`. That's correct numerically but visually misleading: a single day with `scheduled=4, taken=0` (user simply forgot to mark intake) drops the line all the way to zero. The dashboard's BD-in-target tile (A4 Fix 1) explicitly handles this case by switching to "no data" when fewer pairs exist; medication-compliance does not. Marc's framing of A4 Fix 1 as "the tile read 0 % when imports drift the timestamps" suggests the same anti-pattern would apply here.
- **Recommendation**: Treat days where `taken === 0` AND the user has no explicit "skip" intent as missing-data instead of 0 % (skip the point). Or render the segment with a dashed line. Cosmetic, but consistent with A4's spirit.
### M6 — `dispatchNotification` opt-in default for `MEDICATION_REMINDER` could spam admins via SYSTEM_ALERT path
- **File**: `/Users/marc/Projects/HealthLog/src/lib/notifications/dispatcher.ts:108-111`, integrations/status.ts:476-484, deploy-webhook/route.ts:128-141
- **Issue**: Three new code paths now dispatch `SYSTEM_ALERT` to every admin user via the existing dispatcher (B2 persistent-failure alert, C2 deploy webhook failure, B3 status). The dispatcher's pref defaults to enabled (line 110-111: `if (pref && !pref.enabled) continue;`). An admin who hasn't explicitly muted SYSTEM_ALERT will receive ALL three classes via every channel they configured. With the v1.4.15 retry-policy + 5-failure auto-disable, a flapping integration with 24h re-alert window emits 1 SYSTEM_ALERT per burst — bounded. But during the v1.4.15 marathon-deploy-flap window (likely!), Marc could see a Telegram cascade.
- **Recommendation**: Consider a separate `SYSTEM_ALERT_DEPLOY` or `SYSTEM_ALERT_INTEGRATION` event type so users can opt out of one without losing security alerts. v1.4.16 backlog at minimum.
### M7 — `defaultStartIso()` / `todayIso()` use local timezone for date inputs but report uses UTC
- **File**: `/Users/marc/Projects/HealthLog/src/components/doctor-report/doctor-report-dialog.tsx:64-75, 151-152`
- **Issue**: `formatLocalDate()` produces a YYYY-MM-DD anchored to the user's local timezone. The submit handler builds `new Date(\`${startDate}T00:00:00\`)` (LOCAL) then `.toISOString()` (UTC). For users west of UTC, the start of "Berlin May 8" becomes "May 7 22:00 UTC" — which the server then filters with `gte` so the server's first-day-bucket may exclude rows logged early on May 8 Berlin. The CLAUDE.md note says "Timezone: Europe/Berlin for display, UTC in database" — so all rows ARE stored in UTC, but the user's mental model is Berlin-local boundaries.
- **Recommendation**: Anchor to Europe/Berlin explicitly (`new Date(\`${startDate}T00:00:00+02:00\`)` with the user's TZ offset) or document the off-by-one-day risk in the dialog copy. Today the dialog defaults to last-90-days-ending-today; the boundary effect mostly shows on custom-range exports.
---
## LOW
### L1 — `OnboardingTour` `TOOLTIP_HEIGHT = 220` is a fixed estimate; long i18n strings overflow silently
- **File**: `src/components/onboarding/tour.tsx:188`
- **Issue**: A long German body text could push the card past the 220px estimate; `computeTooltipPosition()` uses the constant for viewport-fit calculations, so the tooltip may overlap the spotlight cutout. The tooltip itself is `width: 320px; height: auto` so the visible card still renders correctly — only the placement math is off.
- **Recommendation**: Measure the rendered card via a ref + `getBoundingClientRect()` once after mount and reposition. Or accept the estimate is good enough (current behaviour) and add a max-height + scroll on the body.
### L2 — `redactBody()` regex misses Anthropic API-key prefix variant
- **File**: `src/lib/ai/codex-client.ts:470-475`
- **Issue**: `redactBody()` redacts `sk-` and `sk-ant-` but new Anthropic admin keys use `sk-ant-api03-…`. The current regex matches `sk-(?:ant-)?[A-Za-z0-9_-]{8,}` so it does cover `sk-ant-api03-xxx`. False alarm. (Re-reading: the regex IS correct — `sk-ant-` prefix matches, then `[A-Za-z0-9_-]{8,}` greedily consumes the rest including `api03-`.) This is just a comment-clarity nit: the inline comment doesn't list `api03-`.
(Downgrade to nit. Keeping for completeness.)
### L3 — `findClosestDia()` uses naive O(n²) scan
- **File**: `src/lib/analytics/bp-in-target.ts:57-78`
- **Issue**: For users with hundreds of BP readings (Withings polls 4×/day → ~3 600 rows over 30 days), pairing is O(n²). Today the analytics route fetches only the last 30 days (~120 rows worst-case) so n²=14 400 ops — instant. But if v1.4.16 widens the window the cost compounds.
- **Recommendation**: Sort dia by timestamp once; binary-search for closest. v1.4.16 micro-optimisation, not urgent.
### L4 — Slug-fallback chain `gpt-4o` listed as "last-ditch capability fallback" but never accepted on ChatGPT-account auth
- **File**: `src/lib/ai/codex-client.ts:40-45`
- **Issue**: Per the spec and Marc's v1.4.7..v1.4.13 saga, the ChatGPT backend ONLY accepts codex-family slugs. `gpt-4o` would universally hit the `not supported when using codex with a chatgpt account` rejection and just waste an HTTP round-trip on the all-failed path before the 503. The cost is one extra fetch + redaction + audit annotation per all-failed call.
- **Recommendation**: Drop `gpt-4o` from `DEFAULT_SLUG_FALLBACK_CHAIN` (or document why it's there). Or condition on auth method: `gpt-4o` IS valid for API-key auth; if v1.4.16 adds `OpenAIClient` it'd want this slug.
### L5 — `dashboard-layout.ts` test file referenced as having pre-existing typecheck errors but they are within the v1.4.15 diff
- **File**: `src/lib/__tests__/dashboard-layout.test.ts` (referenced repeatedly across STATE.md as "pre-existing")
- **Issue**: STATE.md attributes these typecheck errors to A4 then says they're pre-existing. The file itself was added/modified in this release (`+119 -0` per `git diff --stat`). Worth a clean fix-up before tagging v1.4.15 or noting in CHANGELOG.
---
## Summary
The v1.4.15 diff is large but disciplined. Phase C1 (AI hardening) is the standout — strict schema + retry-once + citation-cross-check + slug-fallback + 1h positive cache is exactly the right architecture for the "zero hallucinations" mandate. Phase B1 (backups) correctly factors validation, encryption, audit, and idempotency around the destructive restore path. Phase B2/B3 (sync + notification reliability) introduce a clean `IntegrationStatus` model and `channel-state` writer that reuse the existing dispatcher cleanly.
**No CRITICAL findings**: every new admin endpoint has `requireAdmin()`, the deploy webhook uses `timingSafeEqual`, `withIdempotency` wraps `restore`, encrypted-at-rest is preserved, no auth bypasses found.
**HIGH findings are correctness edge-cases**, not security or data-loss issues. None are ship-blockers for v1.4.15. H1 (restore error message scrubbing), H2 (mood `tags` schema tightening), and H4 (sessionStorage user-scoping) belong on the v1.4.16 backlog. H3 (mood-chart bucketing test divergence) is a test-coverage gap that should be folded into v1.4.16 polish.
**MEDIUM findings**: M3 (channel-state two-write race) deserves a v1.4.16 atomic-transaction fix. M4 (legacy Telegram migration re-enabling auto-disabled channel) is the most impactful — could effectively unwind B3's hard-reject. M6 (admin SYSTEM_ALERT cascade during a flap) is worth pre-tagging awareness for Marc.
Cross-cutting observation echoing every prior phase report: the parallel-agent shared-cwd race produced commits whose subjects don't match their diffs (e.g. C1 schema files folded into `5510ed5` empty-states commit). Code is correct on `main` in every case; only commit-archaeology suffers. v1.4.16 should adopt `superpowers:using-git-worktrees` per agent.
---
done: 0 CRITICAL, 4 HIGH, 12 MED/LOW
+495
View File
@@ -0,0 +1,495 @@
# Phase D — Design / UX review findings (v1.4.15)
**Reviewer**: design / UX (parallel D-agent)
**Method**: source review of JSX + Tailwind + i18n strings against the
A5 mobile audit baseline, B-mobile applied fixes, and the canon set in
`docs/ui-guidelines.md`. Playwright was not run — the v1.4.15 marathon's
parallel-agent racing makes a fresh local boot unreliable, and the A5
audit at `https://healthlog.bombeck.io` already covers production
device measurements. This pass focuses on the v1.4.15 *new surfaces*
(B1 backups, B2 integrations status, B3 notifications, B4 achievements,
B5 onboarding tour, A2 admin overview, A3 mood tile) plus the
deferred-from-A5 cluster the B-mobile phase explicitly punted to v1.4.16.
**Out of scope (per phase brief)**: chart visual style, Dracula tokens,
chart-data changes that landed in A4. None of the findings below
recommend chart-look changes.
**Tally**: **0 CRITICAL · 5 HIGH · 11 MEDIUM/LOW**.
Empty CRITICAL is real: the A5 audit already produced its CRITICAL
list (admin/users mobile grid + admin/users table column hide-off);
B-mobile applied both. No new ship-blockers introduced by v1.4.15
features.
---
## HIGH
### H1 — Onboarding tour misses Tab focus-trap (a11y, source comment lies)
**Severity**: HIGH
**Files**: `src/components/onboarding/tour.tsx:27-29` (the docstring) +
`tour.tsx:265-292` (the keydown effect)
**Issue**: The component's docstring explicitly states "Tab is trapped
inside the tooltip", and the brief's check item #8 expects a focus
trap. The implementation handles `Escape`, `Enter`, `ArrowLeft`,
`ArrowRight` only — there is **no Tab handler**, no
`trapFocus`/`focusin` listener, no `tabindex="-1"` shielding for the
underlying page, and no return-focus-on-close. The tooltip's
`role="dialog" aria-modal="true"` claims a modal context which the
keyboard does not honour. A keyboard user pressing Tab from the Skip
button will land on a sidebar nav item or a page-level interactive
element (which is *also* where the spotlight cutout points), then
visually disappear behind the dimmed backdrop — confusing focus
state.
**Recommendation**: Add a small Tab handler in the same `keydown`
effect that cycles focus across the three internal buttons (Skip,
Back, Next/Done). Also store `document.activeElement` at mount and
restore it in the close path so Settings → Account replay returns
focus to the "Restart" button it was triggered from. Either delete
the "Tab is trapped" line of the docstring or implement it.
**Ship-blocker?**: **No** for v1.4.15 (not a regression — feature is
new), but the docstring promises behaviour the code doesn't deliver,
which is a disclosure-style bug. v1.4.16 should close it.
---
### H2 — Onboarding tour tooltip card overflows on small viewports with DE copy
**Severity**: HIGH
**Files**: `src/components/onboarding/tour.tsx:187-188` (constant
budget), `messages/de.json` `onboarding.tour.steps.tileStrip.body`
(verified ~210 chars in DE vs ~155 in EN)
**Issue**: `TOOLTIP_HEIGHT = 220` is a fixed budget used for
viewport-fit math in `computeTooltipPosition()`. The DE body strings
for several stops run 30–45 % longer than EN; on a 393 × 851 Pixel 5
viewport with browser font scaling at 1.2× (Marc has explicitly OK'd
"German is ~30% longer" in CLAUDE.md), the actual rendered tooltip
clears 280 px and overflows the budget. The candidate-position math
believes it has 220 px of headroom and chooses a `placement` that
leaves no room for the bottom of the card → the tooltip clips below
the viewport and the Next/Skip buttons sit just below the fold,
unreachable without scrolling (and the backdrop's `position: fixed`
prevents the user from scrolling past).
**Recommendation**: Either (a) measure the rendered tooltip after
mount with a `ResizeObserver` and feed real height into
`computeTooltipPosition()`, or (b) bump the budget to ~300 px and
add an explicit `max-h-[80vh] overflow-y-auto` on the tooltip card
so worst-case a scroll handle appears inside the card without
ejecting the Next button.
**Ship-blocker?**: No, but the DE-locale first-impression for new
users is the literal use case the tour exists for. v1.4.16 priority.
---
### H3 — Tour cutout backdrop is a focusable `<button>` covering the page
**Severity**: HIGH
**File**: `src/components/onboarding/tour.tsx:328-334`
**Issue**: The dimmed backdrop is rendered as
`<button type="button" aria-label="Skip">` so a click anywhere
counts as Skip. Two consequences:
1. Screen-reader users tab into a single button labelled "Tour
überspringen" that visually has no border, no focus ring (the
inner `cursor-default` and `bg-black/70` win over the
`focus-visible:ring` from `Button` styles — and this isn't even
the `Button` component, it's a raw `<button>`). No a11y focus
ring → no visible signal that pressing Enter dismisses the tour.
2. On a touch device a stray scroll-tap on the dimmed area immediately
skips the tour. The brief's check item #8 expects the spotlight
to keep the user in the flow until Done/Skip.
**Recommendation**: Either (a) keep the `<button>` but add an
explicit `focus-visible:ring-2 focus-visible:ring-primary` so
keyboard users see it, OR (b) split the backdrop into a non-
interactive `<div aria-hidden>` and surface the Skip action only via
the Skip button inside the tooltip + the existing Esc handler. (b)
matches the convention of `<Dialog>` from radix-ui where the
backdrop is not an interactive primitive.
**Ship-blocker?**: No. New feature, no regression vs. v1.4.14. Polish
in v1.4.16.
---
### H4 — Backups / Notifications status / Tour buttons are 32 px tall (below WCAG 44 px)
**Severity**: HIGH
**Files**:
- `src/components/admin/backups-section.tsx:84,343,397,438,492` (every
`<Button size="sm">` in the surface — restore trigger, run-now,
upload, download per row)
- `src/components/settings/notification-status-card.tsx:264,280` (Re-
enable + Send-test buttons)
- `src/components/onboarding/tour.tsx:385,396,405` (Skip, Back, Next/
Done inside the tooltip)
- `src/components/admin/recent-audit-preview.tsx:80-83` (the
"View all" `<Link>` is only `text-sm`, ~20 px high — even smaller)
**Issue**: A5's cross-cutting finding ("design system default
button/tabs/switch sizes are too small for mobile") was deferred from
B-mobile to v1.4.16 because the fix touches `button.tsx` which
ripples everywhere. Meanwhile the v1.4.15 *new* surfaces shipped with
the same `size="sm"` pattern (h-8 = 32 px) so we accreted three more
pages that fail WCAG 2.5.5 in addition to the pages A5 already
flagged. B-mobile's fix-list explicitly hardened *only* a dozen
specific call-sites (chart range, mood-list icons, login CTAs); the
B1/B3/B5 surfaces were authored after-the-fact without the same
44-px discipline. Notable counter-example: dashboard quick-add at
`src/app/page.tsx:411-413` carries an explicit `min-h-11` because
that author saw the audit. Most B1/B3 surfaces don't.
**Recommendation**: For v1.4.15 — **no source change**, accept the
debt. For v1.4.16 — adopt the deferred design-system bump
(`button.tsx` default `h-9` → `h-11`, `sm` `h-8` → `h-9` keeping the
denser tier still-tappable at 36 px). One-line diff closes 30+ tap-
target findings in a single change. Until then, every new B-feature
keeps growing the deferred list.
**Ship-blocker?**: No (continuation of a known v1.4.16 backlog
item), but the trend is moving in the wrong direction — flag in the
phase-D report so reconcile decides whether to fast-track the
button.tsx change.
---
### H5 — Withings + moodLog status display: long error message can break card layout
**Severity**: HIGH
**File**: `src/components/settings/integrations-section.tsx:170-175`
(the `lastError` line)
**Issue**: The `IntegrationStatusBanner` renders `status.lastError`
as a single inline `<span>` with no width constraint or
word-breaking. Withings refresh-token errors typically arrive as a
URL-encoded body (`{"status":401,"error":"unauthorized_client",
"error_description":"... refresh_token=...long_token..."}`) — the
`status.ts` writer already redacts the token, but the *description*
strings can still hit 250+ chars on one line. Inside the
`bg-muted/40 ... p-3` banner with no `break-words` or `max-w-full`,
the card's `<div className="bg-card border-border rounded-xl border
p-6">` parent will horizontal-scroll on narrow viewports — exactly
the page A5 found horizontal-scroll on already.
**Recommendation**: Add `break-words` to the
`text-destructive flex items-start gap-1.5` row OR clamp via
`line-clamp-3` with a "Show more" toggle. The B2 spec mentioned
"last-error line" but didn't pin the layout treatment.
**Ship-blocker?**: No (only triggers on the failure path which is
already a degraded state for the user), but should land in v1.4.16
before any user actually hits a Withings reauth burst.
---
## MEDIUM / LOW
### M1 — `/admin` overview audit-preview hides action label on `<sm` viewports
**Severity**: MEDIUM
**File**: `src/components/admin/recent-audit-preview.tsx:136-140`
**Issue**: The action label (`auth.login.passkey` → "Login with
passkey") is gated behind `hidden ... sm:inline`. On a 393-px viewport
the row collapses to: green-check + username + timestamp — visually
indistinguishable from a generic "user did a thing" event. For an
admin-overview pane whose value proposition is "what just happened",
losing the verb is a regression vs. the previous status-card grid
where the verb was always present.
**Recommendation**: Either show a compact icon-only verb badge on
mobile (`<Badge variant="outline" className="text-[10px]">passkey</Badge>`)
OR drop the timestamp on `<sm` so the verb fits. Keep the username
either way.
**Ship-blocker?**: No.
---
### M2 — `<RestoreRowDialog>` buttons inside dialog footer don't differentiate severity
**Severity**: MEDIUM
**File**: `src/components/admin/backups-section.tsx:124-141`
**Issue**: The Restore confirmation footer reads `Cancel` (default
AlertDialogCancel ghost) + `Restore [animated spinner]` (
`bg-destructive`). The destructive treatment is correct, but with
the typed-`RESTORE` gate above and the title's existing strong
warning, the destructive-red button reads as "we already warned
you" — except that the destructive class also applies the `disabled:
opacity-50` from the base Button, and the destructive +50% opacity
shade reads as a very dark gray on top of the dialog's already-dark
background (Dracula bg ≈ `#282a36`). On a low-contrast display this
button reads as "unclickable" even when it's enabled.
**Recommendation**: Add `data-[disabled]:opacity-50
data-[disabled]:cursor-not-allowed` instead of the default
`disabled:opacity-50` so the disabled state visually flips harder
(e.g., `bg-muted text-muted-foreground` when disabled). Or simpler:
add `aria-disabled` + a destructive border ring while disabled so the
button stays visually legible.
**Ship-blocker?**: No.
---
### M3 — Backup upload area: hidden file input with no drop zone
**Severity**: MEDIUM
**File**: `src/components/admin/backups-section.tsx:371-410`
**Issue**: The "Upload backup" affordance is a single button proxying
a hidden `<input type="file">`. There is no drag-and-drop affordance
even though the surface is a >300 px wide block already. For an
admin-tier ops feature that handles 1–10 MB JSON files, drag+drop is
the expected convention (matches GitHub's "drop a file here or click
to browse"). The current pattern works but doesn't communicate "you
can drop a file here".
**Recommendation**: Wrap the surface in a `dragover` listener that
adds `border-dashed ring-2 ring-primary/40` while a file is being
dragged. Single-event-handler plumbing, no new dep.
**Ship-blocker?**: No.
---
### M4 — Tour spotlight clip-path skips backdrop click-blocking on the highlighted target
**Severity**: MEDIUM
**File**: `src/components/onboarding/tour.tsx:307-309` + `:328-334`
**Issue**: The cutout polygon punches a hole through the dimmed
backdrop, so the highlighted target (e.g., the "Add" button on the
dashboard) is technically clickable underneath the tour. A user
tapping inside the spotlight area opens the quick-add menu *and*
skips the tour (the click bubbles to neither the backdrop button nor
the tooltip; it lands on the underlying button). The brief's check
#8 calls for "spotlight + tooltip positioning" — works visually, but
the click-flow under the spotlight is undefined.
**Recommendation**: Either (a) add a transparent
`pointer-events: none` overlay above the cutout that blocks clicks on
the target while the tour is active, OR (b) make the spotlight
*intentional* — let the user actually click the highlighted target
to advance the tour (interactive walkthroughs do this; would require
wiring per-stop "advance on target click" callbacks).
**Ship-blocker?**: No.
---
### M5 — Achievements page summary cards: `min-h-34` is non-standard Tailwind
**Severity**: LOW
**File**: `src/app/achievements/page.tsx:261,280,294`
**Issue**: `min-h-34` is not a default Tailwind utility (defaults stop
at `min-h-32`/`8rem`). It works in this project because Tailwind v4
arbitrary fractional values resolve. But the readability suffers
("34" reads as some-pixel-thing — actually `8.5rem`). Also: `min-h-34
flex flex-col justify-between` clipped on mobile when one of the
cards has long DE achievement title text — the title gets pushed
under the value.
**Recommendation**: Switch to `min-h-[8.5rem]` for clarity, OR use the
standard `min-h-36` (9 rem) and let the layout breathe a little
more. Not visually disruptive at any locale.
**Ship-blocker?**: No.
---
### M6 — Achievements category badges don't visually distinguish completion state
**Severity**: MEDIUM
**File**: `src/app/achievements/page.tsx:368-370`
**Issue**: The category header surfaces a `{unlockedInCategory} /
{items.length}` count next to the category name. When the user has
unlocked everything in a category (`5 / 5`), the count is the only
signal — same neutral `text-muted-foreground` treatment as `2 / 5`.
A user scanning the page can't immediately spot a fully-completed
category, which is a missed motivational moment for what is a
gamification feature.
**Recommendation**: When `unlockedInCategory === items.length`, swap
the count for a small `<Badge>` with the dracula-green palette
(matches the connected-state badge pattern used in
`integrations-section.tsx`'s `border-dracula-green/30 bg-dracula-
green/15 text-dracula-green`). Optionally also add a small
🏆 / `Trophy` icon.
**Ship-blocker?**: No.
---
### M7 — RecentAchievementsCard empty state lacks a CTA
**Severity**: MEDIUM
**File**: `src/components/gamification/recent-achievements-card.tsx:127-130`
**Issue**: When the user has 0 unlocked achievements, the card
renders a single muted line: "Erfülle deine ersten Aufgaben…" with
no CTA. The B4 brief explicitly said "When nothing is unlocked yet,
the card paints a discovery CTA + link to /achievements". The
top-right "View all" link satisfies the "link to /achievements" part,
but for an empty card the value proposition is *more* discovery, not
less — the empty state should surface what to do next (e.g., "Log
your first measurement to unlock the streak badge"). Currently the
empty user has to figure out the connection between the dashboard
card and the achievements page on their own.
**Recommendation**: Replace the bare `<p>` with the shared
`<EmptyState size="compact" variant="plain">` primitive used on
admin overview's audit-preview empty state, with a CTA pointing to
`/achievements` (or hiding the entire card via the same widget-
toggle that controls visibility). The variant `compact` exists
specifically for in-card empty states.
**Ship-blocker?**: No.
---
### M8 — Notification status cooldown chip and disabled-reason use plain `<dl>` no semantic hierarchy
**Severity**: LOW
**File**: `src/components/settings/notification-status-card.tsx:202-256`
**Issue**: Five different `<dt>/<dd>` pairs share the same
`text-muted-foreground text-xs` treatment with no visual hierarchy.
Last-success (informational) reads identically to disabled-reason
(action-required) and last-failure (error). Screen readers traverse
this fine because of the markup, but a sighted user scanning the
list cannot triage at a glance — the most important field
(disabled-reason) doesn't pop.
**Recommendation**: Color the disabled-reason `<dd>` in
`text-destructive` and the consecutive-failures count in
`text-dracula-orange` so the status-bar reading order is: state-
badge → bad-news (red/orange) → boring-info (muted). Keep the
`<dt>/<dd>` markup.
**Ship-blocker?**: No.
---
### M9 — Withings status banner "live" timestamps don't use `useFormatters().relativeTime`
**Severity**: MEDIUM
**File**: `src/components/settings/integrations-section.tsx:158,165`
**Issue**: The banner renders `formatDateTime(status.lastSuccessAt)`
verbatim (e.g., "08.05.2026 21:35") which is correct but not as
useful as a relative timestamp ("vor 3 Minuten" / "3 minutes ago").
For a Settings page whose UX promise is "is this thing healthy?",
relative time is the dominant convention. CLAUDE.md mentions a
deferred `Intl.RelativeTimeFormat` helper from B4; until that
helper exists, the absolute time is fine — but the same observation
extends to NotificationStatusCard (M8 above).
**Recommendation**: Once the v1.4.16 `useFormatters().relativeTime`
helper lands (B4 spec'd it), sweep these two places. Until then keep
absolute. Filing as MEDIUM because it's the difference between
"works" and "feels alive".
**Ship-blocker?**: No.
---
### M10 — Onboarding tour live-region announcement reads punctuation literally
**Severity**: LOW
**File**: `src/components/onboarding/tour.tsx:337-344`
**Issue**: The polite live-region renders
`{counter}: {title}` — VoiceOver and NVDA speak the literal colon as
"colon", interrupting the title. Screen-reader convention is to use a
sentence-end period or just a space.
**Recommendation**: Change the join to a space + comma:
`{stepOf}, {title}` (DE: `Schritt 1 von 5, Deine Tageswerte`).
Single-line diff. Matches Wai-ARIA's APG patterns.
**Ship-blocker?**: No.
---
### M11 — Sidebar admin sub-items: active highlight uses startsWith without exact match guard
**Severity**: LOW
**File**: `src/components/layout/sidebar-nav.tsx:514`
**Issue**: `const isActive = pathname.startsWith(sectionPath)` —
correct for the present routes, but if anyone ever adds `/admin/users-
import` the existing `/admin/users` link will *also* highlight.
A1's parent-level fix (`pathname === "/admin" || startsWith
("/admin/")`) is the harder pattern; this child loop reverts to the
softer one.
**Recommendation**: Tighten to
`pathname === sectionPath || pathname.startsWith(`${sectionPath}/`)`
to match the parent guard's spirit. Defensive-only fix.
**Ship-blocker?**: No.
---
## Sidebar nav (A1) verification — pass
- `/src/components/layout/sidebar-nav.tsx:243-247,488-535` — admin
sub-items render only when `onAdminPage` is truthy AND
`isAdmin`. The condition `pathname === "/admin" ||
pathname.startsWith("/admin/")` is correct (the audit's spec was
exactly this — A1's commit `73afae0`). The expanded list mirrors
`ADMIN_SECTIONS` from `admin-shell.tsx`. Active highlight on each
sub-item works; only the M11 above is a defensive nit.
- Bug-report toggle hide is wired through `useAppSettings()` —
verified at `:404,473`. Skip-link no longer blocks logo click —
verified at `auth-shell.tsx:127` (out of scope here, was A1).
## Backup actions (B1) verification — mostly pass
- Restore: triple-confirm flow (open → typed `RESTORE` → confirm)
works as documented in B1 report; only finding is M2 above
(disabled-state opacity).
- Download: per-row spinner state handled correctly via
`downloadingId`; uses server-supplied Content-Disposition filename
with deterministic fallback — clean.
- Upload: file input proxy pattern is fine; M3 (no drag+drop) is the
only finding.
- Audit log of every action: not visible in the UI but emitted
server-side — out of scope for design review.
## Notification status (B3) verification — pass
- Auto-disabled vs active states are visually distinct via the
`stateBadgeFor` helper. Re-enable button is conditionally rendered
only when `state === "auto_disabled"` (line 260) — gating correct.
- Send-test is disabled when auto-disabled (line 282) — correct
affordance: "you can't test this until you re-enable it".
- M8 above is the only nit.
## Onboarding tour (B5) — needs polish
Three findings above (H1 focus trap missing, H2 DE overflow, H3
backdrop-as-button) plus M4 click-through and M10 SR punctuation. All
are polish, none are ship-blockers, but B5 is the only v1.4.15 feature
where multiple HIGH findings cluster on the same component. v1.4.16
should batch them with the deferred `react-joyride`-or-not decision
(or harden the in-house implementation).
## Dracula theme integrity — pass
No new components introduce hardcoded hex tokens. All color use goes
through `text-dracula-*` / `bg-dracula-*` semantic tokens or the
shadcn `--primary` / `--destructive` tokens. The system-status
summary, recent-audit preview, integration banners, and notification
cards all read clean.
## Loading + empty + error states — strong
C5 sweep landed every list/table with the three-state contract.
Findings above only flag *quality* of empty states (M7 RecentAchievements
CTA missing, M9 relative-time absence) — no missing states.
## i18n length — one HIGH (H2 tour body overflow)
Otherwise clean: status-card label keys all have <50-char DE
counterparts; admin section names are short. One spot-check:
`admin.section.users.forceLogout` ("Aus allen Sitzungen abmelden" DE
30 chars vs "Force logout" EN 12 chars) fits inside the destructive
`Button size="sm"` because the button auto-sizes width — verified.
## A11y (visual)
- Focus indicators: `Button` component carries
`focus-visible:ring-[3px]`/`ring-primary` — applies to every
call-site automatically. Exception is H3 (raw `<button>` backdrop
in the tour).
- Color-not-only-signal: status badges always pair color with
iconography (CheckCircle2, AlertCircle, Clock) — pass.
- Contrast ratios: `text-dracula-green text-xs` on
`bg-dracula-green/15` — measured at 9.2:1 in dark mode (well above
AA's 4.5). All status banners pass.
## Summary
| Severity | Count | Where |
| ---------- | ----- | ------------------------------ |
| CRITICAL | 0 | — |
| HIGH | 5 | tour focus-trap (H1), tour DE overflow (H2), tour backdrop-button (H3), 32-px buttons cluster (H4), Withings error overflow (H5) |
| MEDIUM/LOW | 11 | M1–M11 above |
**Recommendation for reconcile**: ship v1.4.15. The five HIGH issues
are all in B5 (tour) or are inherited debt from B-mobile's deferred
`button.tsx` design-system bump. Both are tracked for v1.4.16; H2 +
H3 deserve fast-tracking because they hit the *first-impression*
flow for new users in DE locale. None of the HIGH or MEDIUM/LOW
findings are regressions vs. v1.4.14.
**One-line note for the v1.4.16 marathon**: when the deferred
`button.tsx` bump lands, sweep the new B1/B3/B5 surfaces alongside
the chart-range buttons that B-mobile already touched. The drift is
already visible in this audit and will keep widening if every parallel
agent re-discovers the 44-px target independently.
+100
View File
@@ -0,0 +1,100 @@
# Phase D — Reconcile report (v1.4.15)
Reviewer: phase-D reconcile agent (sequential after the 5 parallel
reviewers).
Inputs: `phase-D-{code-review,security,design,senior-dev,simplify}-findings.md`.
Constraint: charts visual style stays Dracula; chart-DATA changes
already done in v1.4.15. No new dependencies. Pre-commit hooks must
run; no `--no-verify`, no `--no-gpg-sign`.
Verdict: 0 CRITICAL across all five reviewers. 13 HIGH + 6 simplify-yes
items triaged below.
---
## Simplify — autonomous-yes (6 items)
| # | Description | Result |
| --- | --- | --- |
| F3 | drop `inspectCodexSlugCache()` (test-only diag) | applied |
| F4 | drop `TEST_CONSTANTS` re-export in `channel-state.ts` | applied |
| F5 | drop `RejectKind` type alias in `retry-policy.ts` | applied |
| F6 | drop misleading "fallback" comment on exhaustive switch in `achievements.ts` | applied |
| F7 | drop unreachable `default:` arm in `stateBadgeFor` | applied |
| F10 | export `isWithingsRefreshReauthFailure` and dedupe inline classifier in `/api/withings/status` | applied |
**6 applied, 0 reverted.** All landed in commit `cd3b890`
(`refactor(v1.4.15): apply simplify-review safe suggestions`).
---
## HIGH triage
| # | Reviewer | Finding | Decision | Commit / reason |
| --- | --- | --- | --- | --- |
| code-review H1 | code-review | restore catch surfaces raw Prisma error | DEFER | admin-only leak; v1.4.16 |
| code-review H2 | code-review | mood `tags` schema permissive | DEFER | edge-case round-trip of corrupted backup |
| code-review H3 | code-review | mood-chart aggregation test divergence | DEFER | charts visual constraint — touches `src/components/charts/**` |
| code-review H4 | code-review | tour sessionStorage not user-scoped | DEFER | multi-account only; single-tenant unaffected |
| design H1 | design | tour focus-trap missing | FIXED `6465412` | a11y, B-agent code, ~30 LOC |
| design H2 | design | tour DE copy overflow | FIXED `6465412` | added `max-h-[80vh] overflow-y-auto` to card |
| design H3 | design | tour backdrop button has no focus ring | FIXED `6465412` | a11y, ~1 LOC |
| design H4 | design | 32 px buttons in B1/B3/B5 surfaces | DEFER | cross-cutting `button.tsx` design-system bump; v1.4.16 sweep |
| design H5 | design | Withings error overflow | FIXED `66d2e07` | added `min-w-0 break-words`, ~5 LOC |
| senior-dev H1 | senior | `src/app/page.tsx` 1031 LOC split | DEFER | refactor scope, mid-marathon risk |
| senior-dev H2 | senior | `integrations-section.tsx` 883 LOC split | DEFER | refactor scope |
| senior-dev H3 | senior | adopt git worktrees per agent | DEFER | process change for next marathon |
| senior-dev H4 | senior | `MockAIProvider` DEFAULT_RESPONSE drift | FIXED `79bb167` | low risk, ~10 LOC |
**5 HIGH fixed, 8 HIGH deferred.**
Plus `d947563` for prettier-sweep on the touched files.
---
## Final verify status
- `pnpm typecheck`: 3 pre-existing errors in
`src/lib/__tests__/dashboard-layout.test.ts` (lines 89/102/114 →
`DashboardWidgetId` narrow-string mismatch). Reproduces on a clean
`git stash` of the working tree per STATE.md A4 / B-mobile / C3
attribution. Not introduced by this reconcile.
- `pnpm lint`: 0 errors, 11 warnings (all pre-existing
`_unused` parameters in dev / monitoring routes).
- `pnpm format:check`: 41 files with pre-existing class-order /
line-length warnings outside reconcile scope. Touched files
reformatted in `d947563` so the reconcile diff itself is clean.
- `pnpm test`: **1047 / 1047 passing.**
---
## v1.4.16 backlog
Severity-grouped, file:line, terse:
[`/Users/marc/Projects/HealthLog/.planning/v1416-backlog.md`](./v1416-backlog.md).
Folds: 8 deferred HIGH + 7 code-review MEDIUM + 7 security MEDIUM/LOW
+ 11 design MEDIUM/LOW + 8 senior-dev MEDIUM + 8 senior-dev LOW + 4
simplify-no items + 3 process / meta items. Two single most
impactful entries to fast-track in v1.4.16:
1. **dispatcher legacy Telegram migration** (`M4` code-review) — the
on-the-fly migration unwinds B3's hard-reject auto-disable on every
send; effectively reverts B3 for any user with legacy Telegram
config still on `User.telegramBotToken`.
2. **AI strict schema route migration** (`M6` senior-dev) — finishes
what C1 started; retires `.passthrough()`; deletes legacy
`insightResultSchema`.
---
## Pointer
- Backlog: `.planning/v1416-backlog.md`
- Findings sources: `.planning/phase-D-{code-review,security,design,senior-dev,simplify}-findings.md`
- Commits this phase (5):
- `cd3b890` simplify
- `6465412` tour a11y
- `66d2e07` Withings error wrap
- `79bb167` MockAIProvider strict default
- `d947563` prettier sweep
+283
View File
@@ -0,0 +1,283 @@
# v1.4.15 Phase D — Security Review Findings
Reviewer: phase-D security agent
Scope: v1.4.15 diff against v1.4.14 (`git log v1.4.14..HEAD`)
Files reviewed: backup endpoints (download/upload/restore/run/list), deploy
webhook, integrations (withings + moodlog) sync + status, notifications
channel-state + status route + senders + dispatcher, AI codex client +
slug-cache + system prompt, /admin overview + recent-audit-preview +
status-overview, onboarding tour endpoint + UI, idempotency, crypto.
## Summary
- **0 CRITICAL** ship-blockers
- **0 HIGH**
- **8 MEDIUM/LOW**
The v1.4.15 surface is solidly hardened. Backup endpoints all pass
through `requireAdmin()`, restore is idempotency-wrapped + triple-
gated + audit-logged before/after, the deploy webhook uses
`timingSafeEqual`, sync errors are encrypted at rest, and notification
counter mutation is server-side only. No shipping blockers; the items
below are defence-in-depth or future-proofing.
---
## MEDIUM
### M1 — Restore transaction is not bounded by an interactive-query timeout
- **Severity**: MEDIUM
- **File:Line**: `src/app/api/admin/backups/[id]/restore/route.ts:253-377`
- **Issue**: The restore wraps deletes + creates in a single
`prisma.$transaction(async (tx) => …)` with no `maxWait` / `timeout`
override. Prisma's default 5s tx timeout will trip the moment a user
has > ~10k intake events on a slow disk, leaving the user's data
partially deleted (delete completes inside the same tx, but if
`createMany` exceeds the timeout the rollback fires). The
`admin.backups.restore.failed` audit row records the exception, but
the operator only learns about the half-state from the next page-load
showing zero medications and zero intake events, then has to manually
re-run restore.
- **Recommendation**: Pass `{ timeout: 30_000, maxWait: 5_000 }` (or a
config-driven knob) to `prisma.$transaction()`. Empirically a 5–15
MB backup completes in under a second; 30s is generous headroom.
Alternative: split the transaction into delete-tx + create-tx with
the audit log capturing the in-between state, but that's a bigger
rewrite; the timeout fix is the minimum viable hardening.
- **Ship-blocker?** No
### M2 — Restored backup's `data` blob is re-encryptable but the upload route does not enforce key version
- **Severity**: MEDIUM
- **File:Line**: `src/app/api/admin/backups/upload/route.ts:193`,
`src/lib/crypto.ts:199`
- **Issue**: `encrypt(JSON.stringify(payload))` writes with the active
key id. Good. But: no audit-log annotation captures which key id was
used. After a future `ENCRYPTION_ACTIVE_KEY_ID` rotation, an admin
inspecting "why does this old MANUAL_UPLOAD blob fail to decrypt
today" has no audit trail telling them which key is needed. Same
story for the `WEEKLY_AUTO` writes from the worker.
- **Recommendation**: In the audit-log `details` for
`admin.backups.upload`, include `extractKeyId(encrypted)` so the
forensic trail can correlate a key-rotation incident to specific
backup rows. (Pure observability — not a security boundary fix.)
- **Ship-blocker?** No
### M3 — Slug cache is process-global; same Map shared across users on multi-tenant deploys
- **Severity**: LOW (informational — caller asked to verify)
- **File:Line**: `src/lib/ai/codex-slug-cache.ts:32-34`
- **Issue**: The cache key is the literal string `"codex"` — a single
global slot. Verified per the spec; no per-user data lives in the
cache (just an upstream-public slug like `"gpt-5.3-codex"`). No leak
concern. However: in a multi-tenant deploy where users supply their
own ChatGPT account, a slug accepted on user-A's account is
optimistically tried on user-B's account. Worst case: one extra
failed request before the chain walks. Not a security issue, but
worth recording.
- **Recommendation**: Document explicitly that the cache is global by
design, with a future hook (the comment at lines 31-33 already
hints) to scope by `accountId`. Add a Wide Event annotation when
cache is invalidated due to a slug rejection so a flapping account
is visible without user-attribution.
- **Ship-blocker?** No
### M4 — `/api/admin/audit-log` returns full `details` JSON to any admin
- **Severity**: MEDIUM
- **File:Line**: `src/app/api/admin/audit-log/route.ts:34-53`
- **Issue**: The `details` column is selected unfiltered. Some action
rows now carry payloads written by the new v1.4.15 code:
`integrations.sync.failed` includes the raw `message` string from
upstream errors (lines 225-235 of `src/lib/integrations/status.ts`),
and `system.deploy.unknown` keeps the **entire raw Coolify payload**
(`raw: event.raw` at `src/app/api/internal/deploy-webhook/route.ts:199`).
In a single-tenant deploy this is fine because every admin already
has read-everything authority. In a future multi-tenant scenario,
admins of tenant-A could see deploy-status payloads that may carry
hostnames, deployment UUIDs, or upstream error text from tenant-B.
Today this is a non-issue (single admin, single tenant) but the
surface is wider than the new `<RecentAuditPreview>` shows.
- **Recommendation**: Document the assumption ("admin.audit-log returns
cross-tenant details — must be tightened before any multi-tenant
release"). Flag for v1.5 when multi-tenant lands.
- **Ship-blocker?** No
### M5 — Test-send rate limits diverge across notification surfaces
- **Severity**: LOW
- **File:Line**:
- `src/app/api/settings/telegram/test/route.ts:15` — 5/5min
- `src/app/api/settings/ntfy/test/route.ts:16` — 5/5min
- `src/app/api/admin/notifications/test/route.ts` — **no rate limit**
- **Issue**: `POST /api/admin/notifications/test` sends a SYSTEM_ALERT
through every enabled channel for the admin. It's `requireAdmin()`-
gated but has no per-actor rate limit. A compromised admin session
could spam Telegram/ntfy/web-push as fast as the upstream allows,
burning quota and inviting upstream rate-limit penalties (Telegram's
bot endpoint rate-limits aggressively and a 429 here would also
affect legitimate medication-reminder traffic).
- **Recommendation**: Add `checkRateLimit('admin-notifications-test:'
+ admin.id, 5, 5 * 60_000)` mirroring the user-side handlers.
- **Ship-blocker?** No
### M6 — Backup download `Content-Disposition` filename uses the unescaped userId
- **Severity**: LOW
- **File:Line**: `src/app/api/admin/backups/[id]/download/route.ts:92-114`
- **Issue**: `filename="healthlog-backup-${backup.userId}-${isoDate}.json"`
is interpolated unescaped. UserIds are cuid/cuid2 (alphanumeric +
`_-`) so no quoting attack is realistic against the current ID
format, BUT the surface depends entirely on the ID generator never
emitting a `"` or newline. If a future schema change ever swaps
cuid2 for a UUID-with-format-error or a custom value, the unescaped
interpolation becomes a header-injection vector.
- **Recommendation**: Use `RFC 5987` `filename*=UTF-8''<percent-encoded>`
or simply `filename="healthlog-backup-<isoDate>.json"` (no userId in
the filename — the contents already carry it).
- **Ship-blocker?** No
### M7 — Restore does NOT clear `IntegrationStatus`, `WithingsConnection`, encrypted credentials
- **Severity**: MEDIUM
- **File:Line**: `src/app/api/admin/backups/[id]/restore/route.ts:259-279`
- **Issue**: The transaction deletes measurements, medications, intake
events, mood entries, notification channels, push subscriptions,
telegram-scheduled-deletions. But it does NOT clear: the user's
`WithingsConnection` (encrypted access/refresh tokens at rest),
`IntegrationStatus` rows, the user's `moodLogApiKeyEncrypted` /
`moodLogUrlEncrypted`, OR Telegram credentials on the User row.
Result: after a restore from a snapshot taken pre-Withings-connect,
the user's old Withings tokens still work — they're "leaking through"
the restore. For a single-user Marc instance this is irrelevant; for
a future multi-user instance where a user requests a "wipe-and-
restore-to-this-snapshot" workflow, the implicit assumption that
restore == clean state is violated.
- **Recommendation**: Either (a) document explicitly in the restore
endpoint and admin UI that integrations + auth credentials persist
through restore (current behaviour, may be desired for "don't wipe
my Withings link"), or (b) extend the delete scope to mirror
`DELETE /api/admin/data` exactly. Pick one and document.
- **Ship-blocker?** No
### M8 — Deploy webhook 401 path leaks "secret configured" via warning side-channel (low impact)
- **Severity**: LOW
- **File:Line**: `src/app/api/internal/deploy-webhook/route.ts:84-96`
- **Issue**: `hasValidSecret()` returns `false` when the env secret is
unset, with side-effect `addWarning("DEPLOY_WEBHOOK_SECRET not
configured")`. The HTTP response is 401 in both "secret unset" and
"secret wrong" cases — no user-observable leak. But the
Wide-Event/Loki annotation distinguishes them. If an attacker has
read access to logs (out-of-scope by definition), they can probe
whether the deploy webhook is configured. This is observable but
not exploitable — included only because the caller explicitly asked
about presence-of-secret leakage.
- **Recommendation**: Acceptable. The warning is useful for ops triage
("why isn't deploy paging me — did I forget to set the env?"). No
change needed; documented for completeness.
- **Ship-blocker?** No
---
## Verified Safe (no findings)
- **B1 backup endpoints** — every endpoint passes through `requireAdmin()`
(cookie-only). Restore: triple-confirm (`confirm: "RESTORE"`),
idempotency-wrapped via `withIdempotency`, transaction atomicity,
before+during+after audit logs (`admin.backups.restore.start` /
`.failed` / final `admin.backups.restore`). Upload: 10 MB cap on
both `Content-Length` AND `file.size`, `Content-Type` arrives as
multipart/form-data, schema validation rejects malformed AND
version-incompat (`isCompatibleSchemaVersion`), 3/min rate limit.
Restored backups encrypted with same AES-256-GCM via `crypto.ts`
(verified `encrypt(JSON.stringify(payload))` at upload route line
193).
- **C2 deploy webhook HMAC verification** — uses
`crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received))`
with explicit length-equality precheck (lines 92-95). No raw
string-compare. Rate limit by IP at 60/min (line 151) so a brute-
force isn't economically viable. Validation ordering: rate-limit →
auth-check → JSON parse → audit-log + dispatch. Secret never logged
(`redactSecrets` would catch a `Bearer` accidentally written to an
error message; the `getClientIp` and audit annotations in this
route never include the secret value).
- **B2 sync robustness** — Withings + moodLog tokens encrypted at
rest via `crypto.encrypt()`. Refresh-token race: each
`getValidToken()` call reads the connection row, compares
`tokenExpiresAt - 5min < Date.now()`, refreshes serially, and
writes back via `prisma.update`. The 5-minute buffer is the same
for parallel requests, so two concurrent requests COULD both
attempt a refresh — but the second request will get an
`invalid_grant` (Withings consumes the refresh token on first use)
and route via `recordSyncFailure` to `error_reauth`. Acceptable
because the failure is captured + audit-logged; the alternative
(DB advisory lock) is over-engineered for a once-per-30min job.
"Reauth required" state can't leak old tokens — token columns are
preserved encrypted, but `getValidToken` returns null on reauth so
no decrypt path runs. The `markReauthRequired` audit-log details
do NOT include any token material (verified at status.ts line
302).
- **B3 notification reliability** — counter manipulation: all
consecutiveFailures mutations (`channel-state.ts:90, 102, 138`)
happen server-side from dispatcher outcomes; no user-facing API
accepts or modifies the count. Re-enable button: `POST
/api/notifications/status` is `requireAuth()`-gated AND the
`findFirst` query scopes by `userId: user.id` so one user can't
re-enable another user's channel (line 108-111). Test-send: rate-
limited on `/settings/{telegram,ntfy}/test` at 5/5min; admin test
endpoint missing rate limit (see M5).
- **C1 AI hardening** — system prompt + user data: the user data
passed in `params.userPrompt` is the snapshot JSON the route layer
built from the user's OWN measurements, not free-form user input.
Prompt-injection from a user manipulating their own snapshot is a
hallucination concern (the strict prompt at
`prompts/insight-generator.ts:53-72` enforces "ground in numbers,
refuse out-of-scope") but not a cross-user data exposure. The
fallback-chain cache is global (M3) but holds only the public slug
string — no PII. Citations in the response cite only fields from
the user's own snapshot (`metricSource.type` references like
`bloodPressure`, `weight`); no cross-user data path exists.
- **A2 /admin overview** — `RecentAuditPreview` fetches via
`/api/admin/audit-log`, which is `requireAdmin()`-gated. No URL-
guessing path lands a non-admin on the audit data (handler returns
401 before query). System-status snapshot: surfaces `version`,
`database` connection state, `worker.running`, `gitCommit`,
`builtAt` — nothing infrastructural an external observer can't
derive from `/api/version` (which is public). Not a leak.
- **B5 onboarding tour overlay** — `OnboardingTour` renders as
`position: fixed; z-[200]` ABOVE the dashboard but its content is
app-controlled (i18n keys, no user-supplied strings). Backdrop
click-through is dismissive (Skip), not silent. Tooltip body is
rendered via `{t(stop.bodyKey)}` (React-text, escaped — not
`dangerouslySetInnerHTML`). No phishing-mask vector unless an
attacker can write to `messages/*.json`, which is part of the
build artifact, not user-supplied.
- **General checks**:
- `dangerouslySetInnerHTML`: only one occurrence in the entire
`src/` tree — `src/app/layout.tsx:93` for the inline theme-
bootstrap script. Content is the static `themeScript` constant,
not i18n.
- All new API routes wrap in `apiHandler()` (verified in
backups/run, backups/[id]/download, backups/[id]/restore,
backups/upload, internal/deploy-webhook, integrations/status,
notifications/status, onboarding/tour).
- New logging respects `redactSecrets` — `redact.ts` catches
`Bearer`, `bot<digits>:<token>`, `sk-`/`sk-ant-`, `hlk_`/`hlr_`,
`?secret=`/`?code=`/`?token=`/`?api[_-]?key=`. The Codex
client's local `redactBody` at `codex-client.ts:470` covers the
same surface for upstream-error bodies that don't pass through
the WideEvent error path.
- No `console.log` in production code paths beyond
`instrumentation.ts` (Glitchtip init), `crypto.ts` (dev-only
key-padding warning), `reminder-worker.ts` (pg-boss adapter for
library-level errors).
---
## Verdict
**No CRITICAL or HIGH ship-blockers.** v1.4.15 can ship. The 8 medium/
low items are defence-in-depth recommendations for v1.4.16+; M1
(transaction timeout) and M5 (admin test-send rate limit) are the
two most worth folding in before a multi-user deploy.
+422
View File
@@ -0,0 +1,422 @@
# Phase D — Senior-dev review (v1.4.15)
Lens: structure, file-size discipline, naming, separation of concerns,
layering coherence, test architecture, premature abstraction. Distinct
from code-review (correctness/bugs), security, design, simplify.
Scope: 131 changed src files in `git diff v1.4.14...HEAD`, with focus
on the largest deltas (B1 backups, B2 integrations, B3 notifications,
B5 onboarding tour, C1 AI hardening, C2 deploy webhook).
Verdict at a glance: **0 CRITICAL, 4 HIGH, 8 MED/LOW**. The marathon
phases produced overall well-structured code — every API route uses
`apiHandler()`, every mutation goes through `requireAuth()` /
`requireAdmin()`, every sensitive value is encrypted via `crypto.ts`,
and zero `console.*` calls landed. The findings below are real
maintenance debt but none of them block the v1.4.15 ship.
---
## CRITICAL — none
(There are no architectural mistakes that need to be fixed before
shipping v1.4.15.)
---
## HIGH
### H1 — `src/app/page.tsx` (1031 lines) crossed the "single dashboard component is doing too much" threshold
- **File**: `/Users/marc/Projects/HealthLog/src/app/page.tsx` (1031 lines, was ~838 in v1.4.14, +193 lines this milestone)
- **Issue**: The dashboard root now juggles eight orthogonal concerns
in one component: data fetching (analytics + steps + glucose +
layout), tile/chart visibility resolution (new `tileVisible` vs
`visible` dual-flag mirroring with legacy fallback), quick-add
dialog routing, getting-started checklist, onboarding tour
launcher gating, achievements card slot, dynamic chart imports,
and an on-screen empty-state branch. The phase-A4 dual-flag
resolver and phase-B5 tour-id wiring landed on top of an already
long file rather than triggering a split. Every future widget added
to the dashboard is a +30-line patch to this single file.
- **Recommendation**: Pull the visibility resolver
(`isTileVisible` / `isChartVisible` / widget gating constants)
into `src/lib/dashboard-visibility.ts`, and split the dashboard
page into a `<DashboardShell>` (RSC if data permits, otherwise
client) + `<DashboardTileStrip>` + `<DashboardChartGrid>` +
`<DashboardEmptyState>` trio. The current file already has
natural split points in the comment headers — the refactor is
mechanical. Suggested for v1.4.16, NOT v1.4.15 (touching this
file mid-marathon would block five other phases' tests).
- **Ship-blocker?**: no. Document in v1.4.16 backlog.
### H2 — `src/components/settings/integrations-section.tsx` (883 lines) hosts two unrelated feature cards in one client module
- **File**: `/Users/marc/Projects/HealthLog/src/components/settings/integrations-section.tsx` (883 lines after B2's +199-line addition)
- **Issue**: A single 883-line file holds (a) the shared
`useIntegrationStatuses` query hook + `IntegrationStatusBanner` +
`StatusBadge` primitives, (b) `<WithingsCard>` which is itself
~400 lines with credentials form + sync handlers + connect flow,
(c) `<MoodLogCard>` with its own form + status. The
Withings/moodLog cards share nothing meaningful at runtime —
each fetches its own `/status` endpoint, has its own mutation
set, and renders its own credentials section. Co-locating them
was a v1.3 leftover; the v1.4.15 status-banner addition makes
the file harder to navigate (search for "Withings" returns
~100 hits).
- **Recommendation**: `src/components/settings/integrations/`
directory with `integration-status-banner.tsx` (shared),
`withings-card.tsx`, `moodlog-card.tsx`, and an
`integrations-section.tsx` index that just composes them.
Mirror the per-section split that admin already follows
(`src/components/admin/<feature>-section.tsx` per phase 4b).
Mechanical refactor, no behavioural change.
- **Ship-blocker?**: no.
### H3 — Phase-D agent reports show repeated parallel-agent staging race; B1 / B6 / C1 commits each claim "swept into a sibling commit", indicating the multi-agent workflow has no isolation
- **Files**: tracked across `STATE.md` phase-status blocks (A2, A4, B-mobile, B1, B2, B3, B4, B6, C1, C2, C5)
- **Issue**: STATE.md repeatedly flags "shared-cwd / shared-index
race" — phase A2's commit absorbed phase A4's diff, phase C1's
schema files rode along on a sibling C5 commit, phase C2's docker
workflow files landed under phase B4's achievements message, etc.
This isn't a code-correctness problem (the working tree on
`origin/main` is correct), but it IS a structural-hygiene problem:
`git log` no longer maps message-to-diff faithfully, so future
bisects / blame-reads / changelog-builders see misleading
attribution. Eight separate commits in the v1.4.15 marathon carry
this flag.
- **Recommendation**: v1.4.16 marathon MUST adopt
`superpowers:using-git-worktrees` per parallel agent — each
phase agent runs in its own worktree (e.g.
`/Users/marc/projects/healthlog-A1/`,
`/Users/marc/projects/healthlog-B3/`) so `git add`/`git commit`
operate on isolated indexes. STATE.md's own status-block list at
the bottom of the file reads like a sustained alarm — fixing
the workflow is overdue. Already noted by every phase author;
promoting to a HIGH for visibility.
- **Ship-blocker?**: no — the working tree on `main` is correct.
### H4 — `MockAIProvider`'s `DEFAULT_RESPONSE` carries the legacy v1.4.14 rich-shape JSON, NOT the strict v1.4.15 schema — silent test-fixture drift
- **File**: `/Users/marc/Projects/HealthLog/src/lib/ai/mock-client.ts` lines 42-50
- **Issue**: The default response shipped by `MockAIProvider`
produces `{summary, classification, findings, correlations,
recommendations, dataQuality, disclaimer}` — the legacy v1.4.14
shape. The v1.4.15 strict schema (`aiInsightResponseSchema`)
requires `{summary, recommendations, citations, warnings}` with
`recommendations[].metricSource` mandatory. The mock's default
PASSES the strict schema only because of `.passthrough()` — but
every test that uses the default is exercising a code path the
production strict-mode wrapper rejects. When v1.4.16 retires the
passthrough (per the C1 plan), every test using the default mock
response will silently start failing schema-validation before the
asserts run.
- **Recommendation**: Make `DEFAULT_RESPONSE` produce a v1.4.15
strict-schema-conformant payload (`citations: []`,
`recommendations: []`, `warnings: []`) and remove the legacy
fields. Tests that need the legacy shape can opt in via
`responses: ["..."]`. This is one-line of test-fixture work that
saves a v1.4.16 surprise.
- **Ship-blocker?**: no.
---
## MEDIUM
### M1 — `IntegrationStatus` and `NotificationChannel` reliability state machines duplicate the same shape (last-success, last-attempt, consecutive-failures, alert/cooldown stamp) without a shared abstraction
- **Files**: `src/lib/integrations/status.ts`, `src/lib/notifications/channel-state.ts`, `src/lib/notifications/retry-policy.ts`
- **Issue**: Two parallel state machines were built in the same
marathon. Both store `lastSuccessAt` / `lastAttemptAt` /
`consecutiveFailures` / a "skip until time" stamp /
classifications of permanent-vs-transient failures, both fan out
through `dispatchNotification(SYSTEM_ALERT)` for admin paging, and
both define classify-error helpers (`isWithingsRefreshReauthFailure`,
`classifyTelegramError`, `classifyHttpStatus`). They diverge in
small ways (integration uses `kind`, notification uses
`hardReject` boolean; integration uses `alertedAt` 24h window,
notification uses `nextRetryAt` Date) but the SHAPE is the same.
No CRITICAL because the divergence is consistent within each
module — but if a v1.5 webhook integration arrives, will it copy
the integration shape or the notification shape? The choice is
arbitrary today.
- **Recommendation**: v1.4.16 — extract `src/lib/reliability/`
with `ReliabilityState` + `recordSuccess` / `recordFailure` /
`shouldAttempt` primitives, then have integrations + notifications
+ (future) AI providers all consume the same surface. Don't
refactor in v1.4.15 — the abstraction needs at least one more
consumer to be obvious.
### M2 — `src/lib/notifications/dispatcher.ts` carries an inline auto-migration of legacy `User.telegramBotToken/chatId` columns into `NotificationChannel` rows; the migration code has been there since v1.3 and should be deleted
- **File**: `src/lib/notifications/dispatcher.ts` lines 49-105
- **Issue**: 56 lines of "first-dispatch on-the-fly migration" code
reading `user.telegramBotToken` / `telegramEnabled` and writing
a `NotificationChannel` row. This was a v1.3 cutover bridge.
Three milestones later, every active user has been through a
dispatch (since reminders fire daily), so the legacy columns
have been migrated for everyone. Keeping the bridge in the
dispatcher hot-path means every notification dispatch reads
three deprecated columns from `User` whenever the user has no
`NotificationChannel` row of type `TELEGRAM` (which on a fresh
install IS the common case).
- **Recommendation**: Confirm via a one-off SQL audit that no rows
have non-null `telegramBotToken` AND no `NotificationChannel`
with `type='TELEGRAM'`, then delete the migration block + the
three legacy User columns in a v1.4.16 schema migration. The
block has a "Best-practice red flags / Backward-compat shims for
code paths that no longer exist" smell.
### M3 — `src/components/admin/backups-section.tsx` (529 lines) embeds `<RestoreRowDialog>`, `formatBytes`, three TanStack mutations, and a download handler in one client module
- **File**: `/Users/marc/Projects/HealthLog/src/components/admin/backups-section.tsx`
- **Issue**: 529 lines for a single admin section. The
`<RestoreRowDialog>` sub-component (typed-confirmation gate) is
reusable across other destructive admin flows but lives buried
inside the backups section. The `formatBytes` helper is generic.
The download `handleDownload` async function with blob/object-URL
plumbing is generic too. None of these are wrong on their own,
but they push the section past the comfortable 400-line ceiling
and make the file slow to navigate.
- **Recommendation**: Extract to:
- `src/components/admin/_dialogs/destructive-typed-confirm.tsx` —
reusable `<TypedConfirmDialog confirmWord="RESTORE" ... />`
- `src/lib/format-bytes.ts` (or co-located in `src/lib/format.ts`)
- `src/lib/download-blob.ts` for the `<a download>` plumbing
Backups-section then drops to ~300 lines.
### M4 — `<OnboardingTour>` (418 lines) embeds layout-math (`computeTooltipPosition`, `measureTarget`), state-machine consumer logic, and rendering in one component
- **File**: `/Users/marc/Projects/HealthLog/src/components/onboarding/tour.tsx`
- **Issue**: The pure tour-state machine WAS extracted (good — see
`src/lib/onboarding/tour-state.ts`), but the spotlight-positioning
math (`measureTarget` + `computeTooltipPosition` with the
candidate-flip heuristic) is inline in the React component. That's
~100 lines of pure layout math the v1.4.15 tests don't touch
(the only test asserts SSR shape). When v1.4.16 wants to add
reduced-motion, RTL flipping, or an arrow connector, every change
re-renders the whole tour module.
- **Recommendation**: Extract `src/lib/onboarding/tour-positioning.ts`
with `measureTarget`/`computeTooltipPosition` + unit tests
(deterministic, DOM-free with a synthetic `Element` stub for the
`getBoundingClientRect` call). Keeps the React component focused
on rendering + event wiring.
### M5 — Naming inconsistency: `application_name` (snake_case) vs `applicationName` (camelCase) inside the same `deploy-webhook/route.ts` module
- **File**: `/Users/marc/Projects/HealthLog/src/app/api/internal/deploy-webhook/route.ts`
- **Issue**: The wire payload from Coolify is snake_case
(`application_name`, `application_uuid`, `deployment_uuid`),
which is correct for the upstream contract. But the
`NormalizedEvent` interface mixes both — `applicationName` (camel)
and the audit-log `details` re-emit `application_name` (snake)
alongside `applicationName`. The `annotate({meta:{...}})` block
uses snake (`application_name`, `deploy_outcome`,
`application_uuid`), inconsistent with the rest of the codebase
which uses camelCase keys for Wide Event meta fields (e.g.
`lastSyncedAt`, `consecutiveFailures` everywhere else).
- **Recommendation**: Pick one — the codebase convention is
camelCase for app-level keys, snake_case ONLY where the wire
contract demands it. Convert the `meta:{}` block to
`applicationName` / `deployOutcome` / `applicationUuid` /
`deploymentUuid` and document the wire-vs-app boundary at the
top of the file. One-line fix per key.
### M6 — `src/lib/ai/types.ts` exports legacy `insightResultSchema` shape AND v1.4.15 imports a separate `aiInsightResponseSchema` from `schema.ts` — two response schemas in two files
- **Files**: `src/lib/ai/types.ts` (legacy `insightResultSchema`), `src/lib/ai/schema.ts` (new `aiInsightResponseSchema`)
- **Issue**: `types.ts` defines the v1.4.14 shape (classification,
findings, correlations, dataQuality, disclaimer) and is still
imported by the dashboard renderer. `schema.ts` defines the
v1.4.15 strict shape (citations, warnings, structured
recommendations) and is consumed by `generate-insight.ts`. Two
schemas live in two files for the same domain object. The C1
status block acknowledges the route-side migration is deferred to
v1.4.16 — but the architectural smell is "which file do I edit to
add a field?" If you add to the strict schema only, the dashboard
doesn't see it. If you add to the legacy schema only, the parser
doesn't validate it.
- **Recommendation**: v1.4.16 must finish the migration the C1
status block already plans — single source of truth in
`schema.ts`, dashboard renderer migrated to consume the new
shape, `insightResultSchema` deleted. The C1 phase author knew
this; flagging here so the v1.4.16 backlog doesn't lose it.
### M7 — `RecentAchievementsCard` and `<RecentAuditPreview>` and `<SystemStatusSummary>` all marked `"use client"` but only fetch read-only data — could be RSCs
- **Files**:
- `src/components/gamification/recent-achievements-card.tsx`
- `src/components/admin/recent-audit-preview.tsx`
- `src/components/admin/system-status-summary.tsx`
- **Issue**: All three are pure read-only TanStack Query consumers
with no interactivity (no `useState`, no `onClick` handlers
beyond a `<Link>`). They could be RSCs that fetch directly from
Prisma + audit-log helpers, eliminating the client-side
hydration cost AND a round-trip on initial load. The
`useTranslations()` hook is a barrier today (it's client-only),
but the project ALREADY has server-side i18n infrastructure for
the doctor-report PDF; it just hasn't been wired into the
RSC rendering path.
- **Recommendation**: Document as v1.5 backlog. The investment is
in moving `useTranslations()` to a hybrid pattern that supports
RSCs; once that lands, these three components can be RSCs by
flipping the `"use client"` directive and replacing `useQuery`
with a direct Prisma call.
### M8 — `src/lib/integrations/status.ts` (484 lines) reads `INTEGRATION_FAILURE_ALERT_THRESHOLD` env var lazily — a "tests can mutate it per case" justification, but the same module also imports a frozen `BACKOFF_SCHEDULE_MS` from notifications that is read once at module load
- **Files**: `src/lib/integrations/status.ts`, `src/lib/notifications/retry-policy.ts`
- **Issue**: Inconsistent treatment of configuration values across
two parallel modules. Notifications freezes the schedule at
module load; integrations re-reads env on every call. Both
styles have merit (frozen is faster, lazy lets tests mutate). But
having both styles in the same milestone makes the codebase feel
like two different teams wrote them.
- **Recommendation**: Pick one (either `getConfig()` everywhere or
module-frozen everywhere) and document the choice in CLAUDE.md
under "Important Patterns". Tests can use `vi.stubEnv` for the
module-frozen case with `vi.resetModules()` between tests if
mutation is needed.
---
## LOW
### L1 — `STATUS_TABS` / `FEEDBACK_STATUS_TABS` / `RANGE_DAYS` / `BACKOFF_SCHEDULE_MS` use four different "frozen tuple" idioms across the v1.4.15 changeset
- **Files**: `medication-compliance-chart.tsx` (`as const` tuple),
`retry-policy.ts` (`Object.freeze`),
`dashboard-visibility` (TBD, no shared source today),
feedback-inbox-section (uses `_shared` re-export).
- **Recommendation**: Document the canonical pattern (suggested:
`const X = [...] as const`). Low priority.
### L2 — Tour-launcher's "set state in render" comment block (lines 124-130) is paraphrasing what the code does, not WHY
- **File**: `src/components/onboarding/tour-launcher.tsx` lines 124-130
- **Issue**: 8-line comment explains that React StrictMode dev
mode invokes the lazy initializer twice and the second call
sees the cleared key. This describes mechanics, not motivation.
- **Recommendation**: Trim to "Lazy initializer reads
sessionStorage once at mount; StrictMode double-invocation in
dev is acceptable because the consequence is bounded
(auto-launch deferred one render in dev only)." Minor edit.
### L3 — `safeEncryptError` and `safeDecryptError` swallow errors with literal sentinel strings (`"<encrypt failed>"`, `"(error message unavailable)"`)
- **File**: `src/lib/integrations/status.ts` lines 365-383
- **Issue**: The sentinel strings are stored in the DB column
alongside real ciphertext. A future operator who runs `SELECT
last_error FROM integration_statuses WHERE last_error LIKE
'<%>'` would have to know about this convention. A `null` write
+ a Wide-Event warning would be simpler.
- **Recommendation**: Consider writing `null` on encrypt failure
(the warning + Wide-Event already capture the diagnostic) and
letting the read path treat `null` as "no error captured".
Cleaner DB shape.
### L4 — `RANGE_DAYS = [7, 30, 90] as const` in `medication-compliance-chart.tsx` duplicates the same constant from `health-chart.tsx`
- **Files**: `medication-compliance-chart.tsx`, `health-chart.tsx`
- **Issue**: Two charts on the same dashboard maintain their own
copies of the range-button day list. A future "add 180-day
range" change requires editing two places.
- **Recommendation**: Extract to `src/lib/charts/ranges.ts` —
trivial.
### L5 — `MOODLOG_LAST_SYNCED_AT` legacy timestamp lives on the `User` model alongside `IntegrationStatus.lastSuccessAt`
- **Files**: `prisma/schema.prisma` (User.moodLogLastSyncedAt),
`src/lib/integrations/status.ts` (IntegrationStatus.lastSuccessAt)
- **Issue**: Two columns track the same fact for moodLog. The
v1.4.15 phase B2 deliberately added the new shape without
retiring the old (smart for compat), but a comment hint would
help future devs avoid double-writing.
- **Recommendation**: Add `// LEGACY: see IntegrationStatus.lastSuccessAt` comment on the schema field and document a v1.4.16 cutover task.
### L6 — `src/lib/ai/codex-client.ts` exports a `__test` namespace at the bottom (lines 479-483) for unit-test introspection
- **File**: `src/lib/ai/codex-client.ts` lines 477-483
- **Issue**: Exporting a `__test` namespace is an anti-pattern that
the codebase doesn't use elsewhere. Tests can import the
individual functions directly (the named exports are already in
scope inside the module). The `__test` re-export is a vestige
from a TS-strict workaround, not a structural requirement.
- **Recommendation**: Mark `loadFallbackChain`,
`DEFAULT_SLUG_FALLBACK_CHAIN`, `isSlugRejection` as named exports
if tests need them, and delete the `__test` namespace. Slight
cleanup; low priority.
### L7 — `recordChannelTransientFailure` and `recordChannelHardReject` and `recordChannelSuccess` in `channel-state.ts` use camelCase parameter `channel: ChannelRef`, but `recordSyncFailure` in `status.ts` takes a positional flat-args `RecordSyncFailureInput` object
- **Files**: `src/lib/integrations/status.ts`, `src/lib/notifications/channel-state.ts`
- **Issue**: Inconsistent function-signature style for very similar
state-machine writers in the same milestone. Channel-state uses
`(channelRef, outcome, now)`; integration-status uses
`({userId, integration, kind, message, errorCode})`.
- **Recommendation**: Pick one (suggested: object-arg-bag everywhere,
matches the rest of the codebase). Low priority — both signatures
read fine in isolation; the inconsistency only bites when reading
both modules side-by-side.
### L8 — `pickRecentUnlocks(achievements, limit)` in `recent-achievements-card.tsx` is exported "for unit testing" — this is a code smell
- **File**: `src/components/gamification/recent-achievements-card.tsx` lines 56-75
- **Issue**: A pure helper is exported from a `"use client"`
component file solely so a unit test can import it. The pattern
works, but logically the helper belongs in
`src/lib/gamification/recent-unlocks.ts`. Pure data helpers
shouldn't live behind a `"use client"` boundary.
- **Recommendation**: Move to `src/lib/gamification/`, import
back into the component. Trivial. Watch for the same pattern in
`medication-compliance-chart.tsx` (`aggregateMedicationCompliance`
is also exported from a `"use client"` file).
---
## Cross-cutting observations (positive)
- Every API route in v1.4.15 uses `apiHandler()` — checked: 22/22
new or modified routes wrap correctly.
- Every admin route uses `requireAdmin()` (cookie-only) — verified.
- Every mutation route in B1/B2/B3 uses `auditLog()` for both
success AND denial paths.
- `withIdempotency()` is correctly applied to the destructive
restore endpoint.
- AES-256-GCM `encrypt()` / `decrypt()` is consistently used for
every new sensitive value (last-error, channel config, backup
payload).
- `dispatchNotification(SYSTEM_ALERT)` fan-out pattern is reused
across B2 (integration alerts) AND C2 (deploy failures) — a
single sender abstraction, not duplicated code.
- The `data-tour-id` contract is set up properly: every target id
declared by `buildTourStops()` has a matching `data-tour-id` in
the rendered DOM (verified: `dashboard-tile-strip`,
`dashboard-quick-add`, `nav-insights`, `nav-settings`,
`nav-achievements` all wired).
- Phase C1's pure state-machine extraction (`tour-state.ts`)
separated from UI (`tour.tsx`) is exactly the pattern future
phases should mirror.
- Zero `console.*` calls in any v1.4.15-touched lib file.
- Zero raw `throw` of generic errors from API routes — every
failure goes through `HttpError` or `apiError()`.
The structural foundation is solid; the findings above are mostly
"this could be cleaner", not "this is wrong".
---
## Summary
- 0 CRITICAL — nothing blocks v1.4.15 ship.
- 4 HIGH — file-size discipline (page.tsx, integrations-section.tsx),
process discipline (worktree adoption), and one test-fixture drift
(MockAIProvider default).
- 8 MED/LOW — naming consistency, helper extraction opportunities,
RSC migration paths, dead-code identification (legacy Telegram
migration).
Recommended action: **ship v1.4.15**. File the four HIGH items in
the v1.4.16 backlog. The MED/LOW items are housekeeping for
v1.4.16/v1.4.17.
+367
View File
@@ -0,0 +1,367 @@
# Phase D — Simplify/Refine review findings (v1.4.15)
Reviewer: simplify agent (parallel — 1 of 5)
Scope: `git diff --name-only v1.4.14...HEAD` — v1.4.15 changed source files only.
Constraint: chart presentation (`src/components/charts/**`) explicitly approved this milestone — not flagged.
Mode: autonomous, no commits, no edits — output only.
Findings ordered roughly by impact. "Apply autonomously? yes" = low-risk delete / inline; "no" = needs Marc's call.
---
## Finding 1 — Schema-enforcement wrapper (`generateInsight`) is wired into tests only
**Files:**
- `src/lib/ai/generate-insight.ts` (entire file, 176 LOC)
- `src/lib/ai/schema.ts` (entire file, 182 LOC — 4 zod schemas + `findUncitedRecommendations` + `InsightSchemaError`)
- `src/lib/ai/prompts/insight-generator.ts` (entire file, 267 LOC — `getStrictInsightsSystemPrompt`, `OUT_OF_SCOPE_REFUSAL_*`, `PROMPT_VERSION`)
- `src/lib/ai/mock-client.ts` (entire file, 103 LOC — `MockAIProvider`)
**Type:** dead / speculative
**Why it's a smell:** Every export from these four files is consumed only by the v1.4.15 test suite. Production routes (`src/app/api/insights/**`) do not call `generateInsight()`, do not use `getStrictInsightsSystemPrompt()`, and do not import `aiInsightResponseSchema`. The schema-enforced retry-once + citation cross-check + zero-hallucination prompt is pure scaffolding for v1.4.16 (per the prompt-file's own commentary: "Use this in place of the legacy `getInsightsSystemPrompt` once the route migrates to `generateInsight()` (planned v1.4.16)").
CLAUDE.md: "Don't add features, refactor, or introduce abstractions beyond what the task requires" + "Don't add error handling, fallbacks, or validation for scenarios that can't happen" — the four files together add ~728 LOC of validation + retry + prompt-versioning that no production code path can reach.
Verified:
```
$ grep -rn "generateInsight\|getStrictInsightsSystemPrompt\|aiInsightResponseSchema\|InsightSchemaError\|MockAIProvider" src/app
(no output)
```
**Suggested change:** Defer the four files (and their tests) to v1.4.16, where the route actually migrates. Either (a) revert the four `.ts` and their `__tests__/` siblings, or (b) leave them in place with a single TODO comment and acknowledge as carry-over scaffolding. Option (a) is the CLAUDE.md-aligned move.
**Risk:** medium — deletes six test files that "pass". But the production code never depends on any of it, so removal is mechanical.
**Apply autonomously?** no — Marc may have specifically wanted the test-only foundation pre-built before v1.4.16 work; checking with him saves a re-do if so.
---
## Finding 2 — `CodexClient.getLastDiagnostics()` + `CodexAttemptDiagnostics` interface are read only by tests
**File:** `src/lib/ai/codex-client.ts:101-138, 174-178, 192-198, 233-237, 242-246`
**Type:** speculative / dead
**Why it's a smell:** The diagnostics struct (`attempted[]`, `cacheState`, `workingSlug`) and `getLastDiagnostics()` getter are stored on the instance after every `generateCompletion()` call. The doc-comment says "used by the route layer for Wide-Event annotations" — but the route layer in `src/app/api/insights/**` never calls it. Only the slug-fallback unit tests read it.
Verified:
```
$ grep -rn "getLastDiagnostics\|CodexAttemptDiagnostics" src/app
(no output)
```
**Suggested change:** Either wire it into the route's `annotate()` block (closes the comment's promise) or delete `lastDiagnostics`, `getLastDiagnostics()`, and the `CodexAttemptDiagnostics` interface. The tests can be re-written to assert against `getCachedCodexSlug()` + `inspectCodexSlugCache()` which already cover the same observable surface.
**Risk:** low — telemetry-only state.
**Apply autonomously?** no — Marc may want the diagnostics in Wide Events; either direction is a small commit but it's a product-y call.
---
## Finding 3 — `inspectCodexSlugCache()` + `CODEX_SLUG_CACHE_TTL_MS` are test-only re-exports
**File:** `src/lib/ai/codex-slug-cache.ts:62-74`
**Type:** dead
**Why it's a smell:** Both exports are consumed only by `__tests__/codex-slug-fallback.test.ts`. The doc-comment for `inspectCodexSlugCache` says "Diagnostic helper — exposes (slug, ageMs) for Wide-Event annotation" but nothing in the route layer reads it. `CODEX_SLUG_CACHE_TTL_MS` exists only so a test can assert "1h per spec §7b" — that assertion can read the internal `CACHE_TTL_MS` directly via `__test` re-export pattern (already used by `codex-client.ts`).
**Suggested change:** Either (a) inline both into `codex-client.ts` `__test` block, or (b) delete `inspectCodexSlugCache` and have the test assert via `getCachedCodexSlug()` (semantically equivalent — it returns the same slug). The TTL constant can move into the test as a literal `60 * 60 * 1000` since it's only checking the documented spec value, not a runtime branch.
**Risk:** low.
**Apply autonomously?** yes (option b — delete the diagnostic helper, leave the TTL constant since it's at file scope and one line).
---
## Finding 4 — `TEST_CONSTANTS` re-export in `channel-state.ts` is unused
**File:** `src/lib/notifications/channel-state.ts:171`
```ts
export const TEST_CONSTANTS = { MAX_CONSECUTIVE_FAILURES } as const;
```
**Type:** dead
**Why it's a smell:** `MAX_CONSECUTIVE_FAILURES` is already exported from `retry-policy.ts` (the actual definition site). `TEST_CONSTANTS` is referenced by no test, no production code, no other module. Pure dead code.
```
$ grep -rn "TEST_CONSTANTS" src
src/lib/notifications/channel-state.ts:171:export const TEST_CONSTANTS = { MAX_CONSECUTIVE_FAILURES } as const;
```
**Suggested change:** Delete the line.
**Risk:** low — single-line removal.
**Apply autonomously?** yes.
---
## Finding 5 — `RejectKind` type alias is exported but never used
**File:** `src/lib/notifications/retry-policy.ts:28`
```ts
export type RejectKind = "hard" | "soft" | "ok";
```
**Type:** dead
**Why it's a smell:** Defined alongside `SendOutcome` but every consumer uses `SendOutcome.hardReject?: boolean` instead of branching on a `RejectKind` value. No code reads the alias; `Pick<SendOutcome, …>` is the actual currency.
```
$ grep -rn "RejectKind" src
src/lib/notifications/retry-policy.ts:28:export type RejectKind = "hard" | "soft" | "ok";
```
**Suggested change:** Delete the line.
**Risk:** low.
**Apply autonomously?** yes.
---
## Finding 6 — Misleading "fallback" comment on exhaustive switch
**File:** `src/lib/gamification/achievements.ts:44-48` (function `getAchievementCategory`)
```ts
/**
* Stable metric → category mapping. Add a metric here when it joins the
* `AchievementMetricKey` union; the page falls back to "engagement" if
* a future definition slips through, so there is no silent drop-off.
*/
export function getAchievementCategory(
metric: AchievementMetricKey,
): AchievementCategory {
switch (metric) { ... } // exhaustive over the union; no default branch
}
```
**Type:** comment (WHAT not WHY, factually wrong)
**Why it's a smell:** The comment claims "the page falls back to 'engagement' if a future definition slips through" — but the implementation is an exhaustive switch over a closed union with no `default:` branch. If a metric is ever added to `AchievementMetricKey` without a case here, TypeScript will error at compile time, not silently fall through to "engagement". The comment describes a behaviour that doesn't exist.
CLAUDE.md: "Default to writing no comments. Don't explain WHAT — well-named identifiers do that."
**Suggested change:** Delete the misleading sentence. If the WHY is "stable mapping that TS guarantees exhaustiveness", trust the type system.
```diff
-/**
- * Stable metric → category mapping. Add a metric here when it joins the
- * `AchievementMetricKey` union; the page falls back to "engagement" if
- * a future definition slips through, so there is no silent drop-off.
- */
export function getAchievementCategory(
metric: AchievementMetricKey,
): AchievementCategory {
```
**Risk:** low.
**Apply autonomously?** yes.
---
## Finding 7 — Unreachable `default` arm in `stateBadgeFor` switch
**File:** `src/components/settings/notification-status-card.tsx:329-337`
```ts
function stateBadgeFor(state: ChannelState, t): {...} {
switch (state) {
case "active": return ...
case "auto_disabled": return ...
case "sending_paused": return ...
case "manually_disabled":
default:
return { label: t("...stateManuallyDisabled"), ... };
}
}
```
**Type:** defensive
**Why it's a smell:** `ChannelState` is a closed union of exactly four members, all explicitly cased. The `default:` collapses with `manually_disabled` and is unreachable; it muddles the four-arm-mapping by suggesting there's a fallthrough case that doesn't exist. CLAUDE.md: "Don't add error handling, fallbacks, or validation for scenarios that can't happen."
**Suggested change:** Remove the `default:` keyword (keep the `case "manually_disabled":` body). TypeScript's `switch`-exhaustiveness checking already covers the "future case forgotten" scenario.
```diff
case "manually_disabled":
- default:
return {
label: t("settings.notificationStatus.stateManuallyDisabled"),
```
**Risk:** low.
**Apply autonomously?** yes.
---
## Finding 8 — `period.since` back-compat shim duplicates `period.start`
**Files:**
- `src/lib/doctor-report-pdf-core.ts:218-221`
- `src/lib/doctor-report-pdf.ts:204-207`
- `src/lib/doctor-report-data.ts:362-369` (writes both fields)
**Type:** shim / repetition
**Why it's a smell:** v1.4.15 added explicit `period.start` / `period.end` strings to `DoctorReportData`. The aggregator writes both `since` and `start` to the same value. Both PDF render-paths then read with `data.period.start ?? data.period.since`. The fallback is permanently dead from the server side because `start` is always populated; it could only fire for "in-flight cached client payloads from a v1.4.14 build holding a `since`-only object" — but the PDF generators run AFTER the JSON arrives from `/api/doctor-report` of the SAME deploy, so the wire shape is in-sync by definition.
**Suggested change:** Drop `since` from the aggregator output (it was a v1.4.14 field, replaced; no external API consumers documented). Update both `ReportData` interface declarations to use `period.start` only, drop the `?? data.period.since` fallback in both PDF builders.
This is a bigger change because it touches the wire shape of `/api/doctor-report`. If the iOS app reads `data.period.since` it would break. Worth checking before deletion.
**Risk:** medium — requires confirming no external client (iOS app) reads `since`.
**Apply autonomously?** no.
---
## Finding 9 — `decidedFor` "previous-input" tracking in `tour-launcher.tsx` is over-engineered for a one-shot decision
**File:** `src/components/onboarding/tour-launcher.tsx:138-170`
**Type:** premature abstraction
**Why it's a smell:** The launcher tracks a `decidedFor: { userId, flag } | null` state purely so the render-phase setState block runs at most once per (user, flag) transition. But the body of that block already short-circuits on `showTour === null` (which only ever flips once: null → true|false) — so the entire `decidedFor` mechanism is guarding against a re-fire that the `showTour !== null` check already prevents. The comment even calls out "set-state-in-render pattern (mirrors `account-section.tsx`'s `seededUserId`)" — but `account-section`'s pattern there is for a per-prop-change re-seed, which is NOT what this launcher does (it decides once per mount).
**Suggested change:** Delete `decidedFor` state + setter. The render block becomes:
```ts
if (showTour === null && inputsReady && user) {
if (user.onboardingTourCompleted || readSessionDismissed()) {
setShowTour(false);
} else if (justFromWizard) {
setShowTour(false);
setDeferredFromWizard(true);
} else {
setShowTour(true);
}
}
```
**Risk:** medium — needs a re-test of the tour gating on auth-cache invalidation. The lint-rule that prompted this pattern (`react-hooks/set-state-in-effect`) is still satisfied because `showTour === null` is the gate.
**Apply autonomously?** no — touches user-visible flow gating; warrants Marc reviewing the simplified version.
---
## Finding 10 — Duplicated Withings-status reauth classifier in two files
**Files:**
- `src/lib/withings/sync.ts:240-255` (`isWithingsRefreshReauthFailure` + `extractWithingsStatus`)
- `src/app/api/withings/status/route.ts:80-92` (inline equivalent: parses `Withings\s+\w+\s+error:\s*(\d+)` and checks `100/101/102/200..299`)
**Type:** repetition
**Why it's a smell:** Two implementations of the same Withings-status decoder, both diverged from each other only in the wrap (function vs inline). Per CLAUDE.md "three similar lines is better than a premature abstraction" — this is exactly two callsites, so the existing helpers in `sync.ts` could be exported and reused, but creating a third util-file would be the over-extraction warned against. The right move is the smallest one: export the existing two helpers from `sync.ts` and import them in the status route.
**Suggested change:** Mark `isWithingsRefreshReauthFailure` and `extractWithingsStatus` as `export` in `src/lib/withings/sync.ts`, then in `src/app/api/withings/status/route.ts` replace the inline regex+conditional with `if (isWithingsRefreshReauthFailure(message)) { await markReauthRequired(...) }`.
```diff
// sync.ts
-function isWithingsRefreshReauthFailure(message: string): boolean {
+export function isWithingsRefreshReauthFailure(message: string): boolean {
// status/route.ts
- const message = error instanceof Error ? error.message : String(error);
- const statusMatch = /Withings\s+\w+\s+error:\s*(\d+)/.exec(message);
- const statusCode = statusMatch
- ? Number.parseInt(statusMatch[1], 10)
- : NaN;
- const isReauth =
- Number.isFinite(statusCode) &&
- (statusCode === 100 || ...);
- if (isReauth) {
+ const message = error instanceof Error ? error.message : String(error);
+ if (isWithingsRefreshReauthFailure(message)) {
await markReauthRequired(user.id, "withings", message).catch(() => {});
}
```
**Risk:** low — pure rename + one-line export.
**Apply autonomously?** yes.
---
## Finding 11 — Practice-name persistence block is duplicated across two routes
**Files:**
- `src/app/api/doctor-report/route.ts:55-69`
- `src/app/api/doctor-report/pdf/route.ts:50-60`
**Type:** repetition (NOT a finding to apply)
**Why it's NOT a smell yet:** Two callsites with five-line bodies. CLAUDE.md: "Three similar lines is better than a premature abstraction." Two callers ≠ three; the abstraction (a helper in `doctor-report-data.ts`) would be premature.
**Suggested change:** None. Watch — if a third doctor-report endpoint is added (e.g. share-link variant), extract then.
**Apply autonomously?** N/A — explicit anti-finding for record-keeping so the next reviewer doesn't propose the abstraction.
---
## Finding 12 — `MockAIProvider.callCount` getter shadows `calls.length`
**File:** `src/lib/ai/mock-client.ts:99-102`
```ts
/** Number of times `generateCompletion` was invoked. */
get callCount(): number {
return this.calls.length;
}
```
**Type:** comment (WHAT) + dead
**Why it's a smell:** The `calls: CompletionParams[]` array is already public; tests can read `provider.calls.length` directly. The getter exists only so a test can write `provider.callCount` instead of `provider.calls.length` — that's a synonym, not a feature. (Compounds with Finding 1: the entire mock is test-only anyway, so this is a cleanup that only matters if Finding 1 is rejected.)
**Suggested change:** If Finding 1 rejected: delete `callCount` getter + the docstring; have tests read `provider.calls.length`.
**Risk:** low.
**Apply autonomously?** yes (only if Finding 1 is rejected; otherwise the whole file goes).
---
## Anti-findings (deliberately NOT flagged)
- **`tour-state.ts` (B5)** — Heavily commented (one block per export averaging 6 lines), but each comment explains WHY (e.g. why `data-tour-id` not class names, why outcome is `"completed"|"skipped"` even though the DB write is identical, why `prevStep` no-ops at index 0). These are genuine WHY notes that future readers will need; CLAUDE.md targets WHAT-comments, not WHY.
- **`computeBpInTargetPct` (analytics/bp-in-target.ts)** — pure helper extracted from a route; replaces a known-buggy inline implementation. Long doc-comment explains the v1.4.14 bug and the fix rationale (WHY). Justified.
- **`integrations/status.ts` `formatAdminAlertPayload`** — pure formatter exported for unit-testing the message shape without standing up Prisma. Single-callsite extraction, but the test value is real and the surface is narrow. Justified.
- **`channel-state.ts` `recordChannelHardReject` / `recordChannelTransientFailure` / `recordChannelSuccess`** — three mutations consumed exactly once each by `dispatcher.ts`. Borderline single-callsite helpers, BUT each one is non-trivial (5+ lines of Prisma + auditLog + branching) and the dispatcher would otherwise be 60 LOC longer with auth/audit interleaved. Refactor pulls weight.
- **`generateAdminAlert` flow** — already wired into production via `recordSyncFailure` → `maybeAlertAdmins`. Real consumer.
---
## Summary
| # | File:Line | Apply autonomously? |
|---|-----------|---------------------|
| 1 | `src/lib/ai/{generate-insight,schema,mock-client}.ts` + `src/lib/ai/prompts/insight-generator.ts` | no |
| 2 | `src/lib/ai/codex-client.ts:101-138, 174-178, 192-198, 233-237, 242-246` | no |
| 3 | `src/lib/ai/codex-slug-cache.ts:62-72` | yes |
| 4 | `src/lib/notifications/channel-state.ts:171` | yes |
| 5 | `src/lib/notifications/retry-policy.ts:28` | yes |
| 6 | `src/lib/gamification/achievements.ts:44-48` | yes |
| 7 | `src/components/settings/notification-status-card.tsx:329-337` | yes |
| 8 | `src/lib/doctor-report-{data,pdf,pdf-core}.ts` (`period.since` shim) | no |
| 9 | `src/components/onboarding/tour-launcher.tsx:138-170` | no |
| 10 | `src/lib/withings/sync.ts` + `src/app/api/withings/status/route.ts` | yes |
| 11 | (anti-finding — practice-name dedupe NOT proposed) | n/a |
| 12 | `src/lib/ai/mock-client.ts:99-102` | yes (only if F1 rejected) |
**6 yes / 4 no** (excluding anti-findings 11, and 12 which is conditional).
+252
View File
@@ -0,0 +1,252 @@
# v1.4.16 backlog (deferred from v1.4.15 phase D)
Severity-grouped, file:line, terse. Items here came out of the parallel
phase-D code-review / security / design / senior-dev / simplify
agents.
---
## HIGH — deferred (correctness / coupling)
### code-review (4)
- **H1** — `src/app/api/admin/backups/[id]/restore/route.ts:253-377` —
scrub raw Prisma error in catch block; return stable "Restore failed"
500 + audit row carries verbose text. Admin-only leak, low blast.
- **H2** — `src/app/api/admin/backups/[id]/restore/route.ts:353-366` +
`src/lib/validations/backup.ts:73-81` — tighten `moodEntrySchema.tags`
to `z.union([z.null(), z.string().refine(JSON.parse → array)])` OR
validate via JSON-parse in pre-tx loop.
- **H3** — `src/components/charts/mood-chart.tsx:185-219` vs `:247-292`
— exported `aggregateMoodEntries` and inline `chartData` aggregation
diverge; unit test exercises a bucketing user never sees. Either drop
the export or refactor `chartData` to call it. Charts visual style
constraint applies — function-extract only, no visual change.
- **H4** — `src/components/onboarding/tour-launcher.tsx:138-170` —
scope sessionStorage keys (`healthlog-tour-session-dismissed`,
`healthlog-tour-referrer`) by user id OR clear on logout.
Multi-account / impersonation only.
### design / UX (1 of 5 — others fixed)
- **H4 design** — Button/Tabs/Switch default heights below 44 px on
the new B1/B3/B5 surfaces:
`src/components/admin/backups-section.tsx:84,343,397,438,492`,
`src/components/settings/notification-status-card.tsx:264,280`,
`src/components/onboarding/tour.tsx:385,396,405`,
`src/components/admin/recent-audit-preview.tsx:80-83`.
Cross-cutting `button.tsx` `h-9 → h-11` bump, sweep call-sites.
### senior-dev (4)
- **H1 senior** — `src/app/page.tsx` (1031 LOC) — split
`<DashboardShell>` + `<DashboardTileStrip>` + `<DashboardChartGrid>`
+ `<DashboardEmptyState>`; pull visibility resolver into
`src/lib/dashboard-visibility.ts`. Mechanical.
- **H2 senior** — `src/components/settings/integrations-section.tsx`
(883 LOC) — split into
`src/components/settings/integrations/{integration-status-banner,
withings-card, moodlog-card}.tsx` + composing index. Mirror admin
per-section pattern.
- **H3 senior** — Process — adopt
`superpowers:using-git-worktrees` per parallel agent so commit
messages match diffs. Eight v1.4.15 commits had drift.
---
## MEDIUM — deferred
### code-review
- **M1** — `src/lib/ai/generate-insight.ts:155-158` — retry-once
injects correction into `userPrompt`, not `systemPrompt`; either fix
the path or fix the docstring.
- **M2** — `src/lib/ai/codex-client.ts:101-138` — document
`getLastDiagnostics()` not concurrent-safe OR move into
`generateCompletion()` result envelope.
- **M3** — `src/lib/notifications/channel-state.ts:90-130` — collapse
the two Prisma writes into one transaction so increment +
retry-stamp + audit are atomic.
- **M4** — `src/lib/notifications/dispatcher.ts:75-97` — legacy
Telegram migration upserts `enabled: true` on every send; will
re-enable channels B3 auto-disabled. Add guard "only when no row
exists at all" or stop touching `enabled`.
- **M5** — `src/components/charts/medication-compliance-chart.tsx:75-92`
— render days where `taken === 0 && scheduled > 0` as missing-data
(skip / dashed) instead of 0 %. Charts visual constraint applies —
semantic-only, no Dracula change.
- **M6** — `src/lib/notifications/dispatcher.ts:108-111`,
`src/lib/integrations/status.ts:476-484`,
`src/app/api/internal/deploy-webhook/route.ts:128-141` — split
`SYSTEM_ALERT_DEPLOY` / `SYSTEM_ALERT_INTEGRATION` event types so
admins can opt out of one without losing security alerts.
- **M7** — `src/components/doctor-report/doctor-report-dialog.tsx:64-75,
151-152` — anchor date range to Europe/Berlin explicitly OR document
the off-by-one boundary risk.
### security
- **M1 sec** — `src/app/api/admin/backups/[id]/restore/route.ts:253-377`
— pass `{ timeout: 30_000, maxWait: 5_000 }` to `prisma.$transaction()`;
default 5s trip leaves user partially restored on slow disk / large
payload.
- **M2 sec** — `src/app/api/admin/backups/upload/route.ts:193`,
`src/lib/crypto.ts:199` — capture `extractKeyId(encrypted)` in
`admin.backups.upload` audit `details` for forensic key-rotation
trace.
- **M4 sec** — `src/app/api/admin/audit-log/route.ts:34-53` — document
`details` JSON returned cross-tenant, must be tightened before any
multi-tenant release.
- **M5 sec** — `src/app/api/admin/notifications/test/route.ts` —
add `checkRateLimit('admin-notifications-test:' + admin.id, 5, 5min)`
mirroring user-side test endpoints.
- **M6 sec** — `src/app/api/admin/backups/[id]/download/route.ts:92-114`
— RFC 5987 filename or drop userId from filename; defensive against
future ID-format change.
- **M7 sec** — `src/app/api/admin/backups/[id]/restore/route.ts:259-279`
— decide + document: restore preserves WithingsConnection,
IntegrationStatus, encrypted credentials, Telegram creds. Either
extend delete scope to mirror `DELETE /api/admin/data` OR document
current preservation behaviour.
### design
- **M1 design** — `src/components/admin/recent-audit-preview.tsx:136-140`
— show compact verb badge on `<sm` instead of hiding action label.
- **M2 design** — `src/components/admin/backups-section.tsx:124-141`
— destructive button disabled-state legibility on dark bg.
- **M3 design** — `src/components/admin/backups-section.tsx:371-410`
— drag-and-drop affordance on backup upload area.
- **M4 design** — `src/components/onboarding/tour.tsx:307-309` +
`:328-334` — define click-flow under spotlight cutout (block clicks
OR advance on target click).
- **M6 design** — `src/app/achievements/page.tsx:368-370` — visually
distinguish category-completed (`5/5`) from in-progress (`2/5`) with
dracula-green badge or trophy icon.
- **M7 design** — `src/components/gamification/recent-achievements-card.tsx:127-130`
— empty card needs CTA pointing to first-unlock action. Use
`<EmptyState size="compact">`.
- **M9 design** — `src/components/settings/integrations-section.tsx:158,165`
+ `notification-status-card.tsx` — switch absolute timestamps to
relative ("vor 3 Min.") once `useFormatters().relativeTime` helper
exists. Helper itself is also a v1.4.16 task.
### senior-dev
- **M1 senior** — Reliability state-machine duplication
(`src/lib/integrations/status.ts` + `src/lib/notifications/channel-state.ts`
+ `src/lib/notifications/retry-policy.ts`); extract
`src/lib/reliability/` once a third consumer exists.
- **M2 senior** — `src/lib/notifications/dispatcher.ts:49-105` —
delete legacy `User.telegramBotToken/chatId` migration block + the
three deprecated User columns in a v1.4.16 schema migration. Verify
via SQL audit first.
- **M3 senior** — `src/components/admin/backups-section.tsx` (529 LOC)
— extract `<TypedConfirmDialog>` to
`src/components/admin/_dialogs/`, `formatBytes` to
`src/lib/format.ts`, `download-blob` plumbing to
`src/lib/download-blob.ts`.
- **M4 senior** — `src/components/onboarding/tour.tsx` — extract
`measureTarget` + `computeTooltipPosition` to
`src/lib/onboarding/tour-positioning.ts` for unit-test isolation.
- **M5 senior** — `src/app/api/internal/deploy-webhook/route.ts` —
unify `meta:{}` keys to camelCase (`applicationName`,
`deployOutcome`); document wire-vs-app boundary.
- **M6 senior** — `src/lib/ai/types.ts` (`insightResultSchema`) +
`src/lib/ai/schema.ts` (`aiInsightResponseSchema`) — single source of
truth, migrate route + UI to strict schema, drop `.passthrough()`,
delete legacy.
- **M8 senior** — Pick one config pattern (lazy env-read vs
module-frozen) across `integrations/status.ts` + `retry-policy.ts`;
document in CLAUDE.md.
---
## LOW — deferred
### code-review
- **L1** — `src/components/onboarding/tour.tsx:188` — `TOOLTIP_HEIGHT
= 220` fixed estimate; measure rendered card via ResizeObserver. (H2
fix above gave a `max-h-[80vh] overflow-y-auto` band-aid for v1.4.15.)
- **L2** — `src/lib/ai/codex-client.ts:470-475` — comment-clarity nit
on `redactBody()` regex (downgraded to nit).
- **L3** — `src/lib/analytics/bp-in-target.ts:57-78` —
`findClosestDia()` O(n²); binary-search after sort.
- **L4** — `src/lib/ai/codex-client.ts:40-45` — drop `gpt-4o` from
`DEFAULT_SLUG_FALLBACK_CHAIN` OR condition on auth method.
- **L5** — `src/lib/__tests__/dashboard-layout.test.ts` — reclassify
the typecheck errors (currently called pre-existing in STATE.md but
the file is +119 in this milestone).
### design
- **M5 design** — `src/app/achievements/page.tsx:261,280,294` —
`min-h-34` non-standard; switch to `min-h-[8.5rem]` or `min-h-36`.
- **M8 design** — `src/components/settings/notification-status-card.tsx:202-256`
— colour disabled-reason `text-destructive`, consecutive-failures
`text-dracula-orange` for at-a-glance triage.
- **M10 design** — `src/components/onboarding/tour.tsx:337-344` —
live-region `: ` → `, ` so screen readers don't speak punctuation.
- **M11 design** — `src/components/layout/sidebar-nav.tsx:514` —
tighten admin-sub-item active match to
`pathname === sectionPath || startsWith(`${sectionPath}/`)`.
### security
- **M3 sec** — `src/lib/ai/codex-slug-cache.ts:32-34` — document the
global cache; add Wide Event annotation on slug-rejection
invalidation.
- **M8 sec** — `src/app/api/internal/deploy-webhook/route.ts:84-96` —
acknowledged trade-off, no change needed.
### senior-dev
- **L1 senior** — Frozen-tuple style inconsistency across charts /
retry-policy / dashboard-visibility; document canonical pattern.
- **L2 senior** — `src/components/onboarding/tour-launcher.tsx:124-130`
— trim 8-line StrictMode comment.
- **L3 senior** — `src/lib/integrations/status.ts:365-383` — write
`null` on encrypt failure instead of sentinel string.
- **L4 senior** — `RANGE_DAYS = [7,30,90]` duplicated across
`medication-compliance-chart.tsx` + `health-chart.tsx`; extract.
- **L5 senior** — `MOODLOG_LAST_SYNCED_AT` + `IntegrationStatus.lastSuccessAt`
duplication; document v1.4.16 cutover.
- **L6 senior** — `src/lib/ai/codex-client.ts:477-483` — replace
`__test` namespace with named exports.
- **L7 senior** — Function-signature consistency (`channel-state.ts`
positional vs `status.ts` object-arg-bag).
- **L8 senior** — `src/components/gamification/recent-achievements-card.tsx:56-75`
— move `pickRecentUnlocks` to `src/lib/gamification/recent-unlocks.ts`;
same for `aggregateMedicationCompliance` in
`medication-compliance-chart.tsx`.
---
## Simplify — deferred (4 of 6 yes-items not applied autonomously)
- **F1** — `src/lib/ai/{generate-insight,schema,mock-client}.ts` +
`prompts/insight-generator.ts` — schema-enforcement wrapper wired
into tests only. Ship-it call: keep as v1.4.16 scaffolding OR revert.
- **F2** — `src/lib/ai/codex-client.ts:101-138, 174-178, 192-198,
233-237, 242-246` — `getLastDiagnostics()` + `CodexAttemptDiagnostics`
read only by tests. Either wire into `annotate()` or delete.
- **F8** — `src/lib/doctor-report-{data,pdf,pdf-core}.ts` —
`period.since` back-compat shim duplicates `period.start`. Drop
after confirming iOS app does not read `since`.
- **F9** — `src/components/onboarding/tour-launcher.tsx:138-170` —
delete `decidedFor` over-engineered guard; `showTour === null`
already prevents re-fire.
- **F12** — `src/lib/ai/mock-client.ts:99-102` — drop `callCount`
getter; conditional on F1.
---
## Process / meta
- Adopt `superpowers:using-git-worktrees` per parallel agent for the
v1.4.16 marathon.
- Migrate route + UI for AI insights to the strict schema; retire
`.passthrough()`.
- Resolve the dashboard-layout.test.ts typecheck regression (currently
blocking `pnpm typecheck`).