docs(planning): file v1.4.18 reconcile + v1.4.19 backlog + v1.5 update

Wave-D reconcile report at .planning/phase-D-reconcile-report.md;
deferred items seeded to .planning/v1419-backlog.md; strategic
v1.5 items appended to .planning/v15-backlog.md from the v1.4.18
Product-Lead review. STATE.md marks Wave D done.

Prettier sweep also touched the planning markdown table widths;
no content 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-10 11:09:41 +02:00
1 parent 3048dd6637
commit 395129a18c
53 files changed
+3195 -2151

No files matched your search

+36 -36
View File
@@ -4,15 +4,15 @@ Milestone: **v1.4.18** (kicked off 2026-05-10)
Latest tag at start: v1.4.17 (live in prod, image digest
`sha256:936e9cf2…`)
| Phase | Goal | State |
| ----- | ----------------------------------------------------------------------------------------------------------------- | ------- |
| 0 | Bootstrap — STATE+ROADMAP for v1.4.18, git sanity, single chore commit | done |
| A1 | BD-Zielbereich tile — 7T/30T sub-values render real numbers (currently "—" even with data) | pending |
| A2 | `/admin/api-tokens` table scrollbar (3rd attempt) — Playwright live-verify against prod, fix actual overflow | pending |
| A3 | Chart visual revert + per-chart toggles — drop gradient/emoji/auto-mean, ship 3 opt-in overlay toggles | pending |
| B1 | Achievements expansion — research + 15-25 new, hidden Easter-eggs, lock filter, hidden cards | pending |
| D | Multi-agent QA + Product-Lead — code-reviewer, security, design (Apple-Health lens), senior, simplify, product | pending |
| E | Release v1.4.18 — bump, CHANGELOG, tag, GHCR, deploy, /api/version=1.4.18, smoke, docs+landing sync | pending |
| Phase | Goal | State |
| ----- | -------------------------------------------------------------------------------------------------------------- | ------- |
| 0 | Bootstrap — STATE+ROADMAP for v1.4.18, git sanity, single chore commit | done |
| A1 | BD-Zielbereich tile — 7T/30T sub-values render real numbers (currently "—" even with data) | pending |
| A2 | `/admin/api-tokens` table scrollbar (3rd attempt) — Playwright live-verify against prod, fix actual overflow | pending |
| A3 | Chart visual revert + per-chart toggles — drop gradient/emoji/auto-mean, ship 3 opt-in overlay toggles | pending |
| B1 | Achievements expansion — research + 15-25 new, hidden Easter-eggs, lock filter, hidden cards | pending |
| D | Multi-agent QA + Product-Lead — code-reviewer, security, design (Apple-Health lens), senior, simplify, product | pending |
| E | Release v1.4.18 — bump, CHANGELOG, tag, GHCR, deploy, /api/version=1.4.18, smoke, docs+landing sync | pending |
Stop conditions: production red, large un-spec'd issue, context cutoff,
or budget short of polish — write status doc, do not auto-rollback.
@@ -42,33 +42,33 @@ Detailed report: `.planning/phase-v1417-hotfix-report.md`.
## Previous milestone — v1.4.16 (completed 2026-05-10T04:05+02:00)
| Phase | Goal | State |
| -------- | ----------------------------------------------------------------------------------------------------------------- | ----- |
| 0 | Bootstrap — STATE+ROADMAP for v1.4.16 | done |
| A1 | Sidebar admin-expand bug — Admin nav from non-admin route + Gravatar dropdown side-effects | done |
| A2 | BD-Zielbereich real-fix (regression from v1.4.15 A4) — root-cause + E2E | done |
| A3 | `/admin/api-tokens` table responsive — card-list mobile fallback | done |
| A4 | "7-Tage-Schnitt" → "7-Tage-Trend" DE + indicator on ALL charts | done |
| A5 | Top-tile-selector real-fix — widget-id enum drift fixed | done |
| A6 | Medication-chart 7d-trend + target-range — match other charts | done |
| A7 | AI Generator rate-limit 10/h + cache-invalidate-on-new | done |
| A8 | Umlaute encoding bug + login-overview UTF-8 audit + long-window split-half delta | done |
| B1a | Charts visual leap — gradients, baseline, rich tooltip, mood emoji glyphs | done |
| B1b | Insights surface visual leap — page hero, recs grid, summary typography, dashboard preview | done |
| B2 | AI provider settings UX cleanup — single pulldown-driven form, fallback chain editor | done |
| B3 | Admin System-Status host-load chart — CPU/memory/disk-io graph last 2h | done |
| B4 | Admin logs visibility deepening — filterable audit log + structured wide-event tail | done |
| B5a | Medical-reference grounding — AHA / ESC / ESH / WHO bundle + UI citations | done |
| B5b | Multi-provider redundancy — try-each-on-hard-failure, configurable fallback order | done |
| B5c | Per-recommendation explainability — WHY / WINDOW / CITATIONS card with mini-chart | done |
| B5d | Confidence score per recommendation — deterministic 0-100 from sufficiency / recency / signal | done |
| B5e | User-feedback loop — thumbs persisted to RecommendationFeedback + daily aggregator | done |
| B6 | Settings naming-audit — stringency, consistency, no double naming | done |
| B7 | Settings → Export menu (Arztbrief consolidated) — new `/settings/export` route | done |
| B8 | Extended comparison views — Vormonat / Vorjahr overlay across charts + tiles + insights | done |
| C | Catch-up — 5 of 8 deferred HIGH + 3 of 5 mobile MED + docker-publish drop qemu-arm64 | done |
| D | Multi-agent QA + Product-Lead — code-review, security, design, senior, simplify, product-lead | done |
| E1-E3 | Release v1.4.16 — bump, tag, GHCR (both green), host-side retag deploy, GH release, docs+landing sync | done |
| Phase | Goal | State |
| ----- | ----------------------------------------------------------------------------------------------------- | ----- |
| 0 | Bootstrap — STATE+ROADMAP for v1.4.16 | done |
| A1 | Sidebar admin-expand bug — Admin nav from non-admin route + Gravatar dropdown side-effects | done |
| A2 | BD-Zielbereich real-fix (regression from v1.4.15 A4) — root-cause + E2E | done |
| A3 | `/admin/api-tokens` table responsive — card-list mobile fallback | done |
| A4 | "7-Tage-Schnitt" → "7-Tage-Trend" DE + indicator on ALL charts | done |
| A5 | Top-tile-selector real-fix — widget-id enum drift fixed | done |
| A6 | Medication-chart 7d-trend + target-range — match other charts | done |
| A7 | AI Generator rate-limit 10/h + cache-invalidate-on-new | done |
| A8 | Umlaute encoding bug + login-overview UTF-8 audit + long-window split-half delta | done |
| B1a | Charts visual leap — gradients, baseline, rich tooltip, mood emoji glyphs | done |
| B1b | Insights surface visual leap — page hero, recs grid, summary typography, dashboard preview | done |
| B2 | AI provider settings UX cleanup — single pulldown-driven form, fallback chain editor | done |
| B3 | Admin System-Status host-load chart — CPU/memory/disk-io graph last 2h | done |
| B4 | Admin logs visibility deepening — filterable audit log + structured wide-event tail | done |
| B5a | Medical-reference grounding — AHA / ESC / ESH / WHO bundle + UI citations | done |
| B5b | Multi-provider redundancy — try-each-on-hard-failure, configurable fallback order | done |
| B5c | Per-recommendation explainability — WHY / WINDOW / CITATIONS card with mini-chart | done |
| B5d | Confidence score per recommendation — deterministic 0-100 from sufficiency / recency / signal | done |
| B5e | User-feedback loop — thumbs persisted to RecommendationFeedback + daily aggregator | done |
| B6 | Settings naming-audit — stringency, consistency, no double naming | done |
| B7 | Settings → Export menu (Arztbrief consolidated) — new `/settings/export` route | done |
| B8 | Extended comparison views — Vormonat / Vorjahr overlay across charts + tiles + insights | done |
| C | Catch-up — 5 of 8 deferred HIGH + 3 of 5 mobile MED + docker-publish drop qemu-arm64 | done |
| D | Multi-agent QA + Product-Lead — code-review, security, design, senior, simplify, product-lead | done |
| E1-E3 | Release v1.4.16 — bump, tag, GHCR (both green), host-side retag deploy, GH release, docs+landing sync | done |
Marathon completed 2026-05-10T04:05+02:00 — v1.4.16 LIVE in prod.
Full report: `docs/audit/v1416-summary.md`. v1.5 backlog seeded at
+21 -12
View File
@@ -111,17 +111,24 @@ Last update: 2026-05-10T10:02+02:00
## Wave D — QA + Product-Lead review
- [ ] code-reviewer
- [ ] security review
- [ ] design / UX review (Apple Health benchmarking lens; verify
chart-revert is clean, no leftover gradients)
- [ ] senior-dev review
- [ ] simplify
- [ ] Product Lead — state of app, biggest items since v1.4.16, v1.5
roadmap update
- [ ] Reconcile applies CRITICAL/HIGH inline
- Detailed report: `.planning/phase-D-report.md` +
`.planning/product-lead-review.md`
- [x] code-reviewer (1 CRITICAL, 5 HIGH, 7 MED/LOW)
- [x] security review (0 CRITICAL, 2 HIGH, 3 MED/LOW)
- [x] design / UX review (0 CRITICAL, 3 HIGH, 11 MED/LOW)
- [x] senior-dev review (0 CRITICAL, 0 HIGH, 6 MED/LOW)
- [x] simplify (7 apply-yes, 0 apply-no)
- [x] Product Lead — strategic v1.5 update filed
- [x] Reconcile applies CRITICAL/HIGH inline
- Status: 2026-05-10T11:08+02:00 — done. C1 hidden-achievement
wire-leak fixed (`545f44c`); 8 of 10 HIGH fixed inline (1
deferred to v1.4.19 — security HIGH-2 i18n bundle strip needs
a build-time hook), all 7 simplify-yes applied (`720e6c8`).
Format sweep across the tree (`3048dd6`). Tests: 1605/1605
unit, 66/66 integration. Typecheck + lint + format-check
clean. Backlog seeded to `v1419-backlog.md`; strategic v1.5
items added to `v15-backlog.md`. Detailed report:
`.planning/phase-D-reconcile-report.md`. Commits on
origin/main: `545f44c`, `720e6c8`, `fbf14fc`, `194ec2f`,
`cf75579`, `c6e3ac6`, `3048dd6`.
## Phase E — Release v1.4.18
@@ -152,6 +159,7 @@ blob when validation fails; rich `<InsightAdvisorCard>` then called
`stripChartTokens(undefined)` and crashed.
Fix:
- `isLegacyInsightPayload()` now flags blobs missing both `summary`
AND `recommendations[]` (the v1.4.14 shape) so the API surfaces
`legacyPayload: true` correctly.
@@ -169,6 +177,7 @@ pre-existing warnings, `pnpm test` 1547/1547 (+7 defensive tests),
`pnpm test:integration` 59/59.
Commits on origin/main:
- `79bfa27 fix(insights): handle legacy cached payload without rationale (regenerate CTA)`
- `adab80a chore(release): v1.4.17`
- `da7070e style(insights): prettier sweep on legacy-payload hotfix files`
@@ -222,7 +231,7 @@ keep the rule.
hotfix and v1.4.16 entries archived above. Working tree carries
4 untracked stale dotted-segment route directories
(`src/app/api/export/{full-backup.json,measurements.csv,
medications.csv,mood.csv}/`) plus 3 untracked v1.4.16 phase
medications.csv,mood.csv}/`) plus 3 untracked v1.4.16 phase
reports (`phase-E1-report.md`, `phase-E2-report.md`,
`phase-E3-report.md`) — same call as v1.4.16 Phase 0: leave them
in place, they belong to previous milestones. Tracked files clean.
+5 -5
View File
@@ -9,10 +9,10 @@ Tracked-files baseline: clean (untracked-only working tree on entry).
- `.planning/STATE.md` rewritten for v1.4.18: `Status: phase-0-done`,
Phase 0 marked complete, Wave A (A1 BD-Zielbereich tile sub-values,
A2 `/admin/api-tokens` 3rd-attempt scrollbar, A3 chart visual revert
+ per-chart toggles), Wave B (B1 achievements expansion with
research + hidden Easter-eggs + lock filter), Wave D multi-agent QA
+ Product-Lead, Phase E release. v1.4.17 hotfix and v1.4.16 release
blocks archived inline above.
- per-chart toggles), Wave B (B1 achievements expansion with
research + hidden Easter-eggs + lock filter), Wave D multi-agent QA
- Product-Lead, Phase E release. v1.4.17 hotfix and v1.4.16 release
blocks archived inline above.
- `.planning/ROADMAP.md` rewritten with the v1.4.18 one-liner table
for Phase 0 / A1-A3 / B1 / D / E. v1.4.17 hotfix line + the full
v1.4.16 / v1.4.15 / v1.4.14 archive tables retained beneath.
@@ -31,7 +31,7 @@ commits. Untracked items left in place (same call as v1.4.16 Phase 0):
`phase-E3-report.md` — v1.4.16 release reports, belong to that
milestone.
- `src/app/api/export/{full-backup.json,measurements.csv,
medications.csv,mood.csv}/` — stale dotted-segment route directories;
medications.csv,mood.csv}/` — stale dotted-segment route directories;
the live plain-segment routes shipped in B7 of v1.4.16 (per
`phase-E1-report.md`).
+1 -1
View File
@@ -23,7 +23,7 @@ Tests:
- `sidebar-nav.test.tsx` — every admin-route case asserts ONLY the
single `/admin` link is present, never sub-route links.
- New e2e `e2e/sidebar-admin-no-expand.spec.ts` (desktop-only project)
covers (a) /admin/* shows one link no sub-list, (b) clicking Admin
covers (a) /admin/\* shows one link no sub-list, (b) clicking Admin
from /dashboard navigates without flashing sub-items, (c) opening
the gravatar user-menu does not expand sidebar admin sub-items.
+7 -1
View File
@@ -6,6 +6,7 @@ Started: 2026-05-09T~10:08+02:00
Finished: 2026-05-09T~10:15+02:00
## Symptom
Marc reported the dashboard "BD im Zielbereich" tile rendered the
30-day headline correctly (**50,0 %**, the v1.4.16 A2 ceiling-semantics
fix) but the row underneath the headline still showed **`7T: —`** and
@@ -13,6 +14,7 @@ fix) but the row underneath the headline still showed **`7T: —`** and
windows.
## Root cause
`/api/analytics` (`src/app/api/analytics/route.ts`) only computed and
returned a single `bpInTargetPct` field — the share over the trailing
30 days. The dashboard tile (`src/app/page.tsx`) explicitly passed
@@ -27,6 +29,7 @@ Confirmed against Marc's prod data on apps-01: user
10 in last 30 days. Both windows have data; nothing should render "—".
## Fix
Added `computeBpInTargetWindows()` next to the existing
`computeBpInTargetPct()` helper in
`src/lib/analytics/bp-in-target.ts`. Same predicate +
@@ -44,6 +47,7 @@ Headline (`bpInTargetPct`) preserved for cached client bundles +
matches `last30Days` so a v1.4.17 PWA displays the same number.
## Tests (TDD)
1. **Unit** (`src/lib/analytics/__tests__/bp-in-target.test.ts`) —
6 new cases for `computeBpInTargetWindows`: empty input,
mixed-window data, null-7d-with-real-30d, default-clock smoke,
@@ -54,15 +58,17 @@ matches `last30Days` so a v1.4.17 PWA displays the same number.
(avoids the boundary flakiness of integer-day seeds) and assert
both windows produce non-null hand-counted shares; second case
verifies a user with only 14-day-old data sees `7T = null,
30T = real`.
30T = real`.
## Verification
- `pnpm test`: **1559 / 1559** green (was 1547; 8 new cases added)
- `pnpm test:integration`: **61 / 61** green (was 59; 2 new cases)
- `pnpm typecheck`: clean
- `pnpm lint`: 12 baseline warnings, **0 new**
## Commit
`23363ca` —
`fix(dashboard): wire 7T and 30T sub-values on the BD-Zielbereich tile`
+19 -5
View File
@@ -8,17 +8,19 @@ Finished: 2026-05-09T23:35+02:00
## A2 — BD-Zielbereich root-cause analysis
### Symptom
Marc reported the dashboard tile rendered **0 %** despite multiple BP
readings clearly inside the well-controlled range. v1.4.15 phase-A4
report claimed this was fixed (commit `a967895`); it was not.
### Investigation
1. Read `src/lib/analytics/bp-in-target.ts` (the v1.4.15 fix). It
correctly fixed the *denominator* (paired-readings instead of
correctly fixed the _denominator_ (paired-readings instead of
`sysData.length`) and added a same-Berlin-day fallback for imports
rounded past the 5-minute pairing window.
2. Read the v1.4.15 phase-A4 report — claim was specifically the
denominator fix. The "in target" *predicate itself* was untouched:
denominator fix. The "in target" _predicate itself_ was untouched:
`sys >= sysLow && sys <= sysHigh && dia >= diaLow && dia <= diaHigh`.
3. Pulled Marc's actual BP measurements from production (apps-01, container
`db-pg8wggwogo8c4gc4ks0kk4ss-212006924671`, user
@@ -35,10 +37,11 @@ report claimed this was fixed (commit `a967895`); it was not.
- 124/82 → dia 82 > 79 → OUT
- 126/80 → dia 80 > 79 → OUT
- 133/95 → both out → OUT
→ **0 / 10 = 0 %**. Calculation is reproducing exactly what Marc
sees. **No bug in the v1.4.15 code; the bug is in the semantic.**
→ **0 / 10 = 0 %**. Calculation is reproducing exactly what Marc
sees. **No bug in the v1.4.15 code; the bug is in the semantic.**
### Root cause
The "in-target" predicate uses the **ESH 2023 narrow goal band** (the
treatment goal for hypertensive patients on therapy: keep BP between
sysLow and sysHigh, not below sysLow because sub-band can indicate
@@ -50,10 +53,11 @@ well-controlled"). The narrow band counts him out for being healthier
than the goal.
This is **hypothesis #2** from the brief (Wrong target range), with the
twist that the issue is the *lower bound being too high*, not the
twist that the issue is the _lower bound being too high_, not the
upper bound being too low.
### Fix
Centralised the predicate in a new exported `isBpReadingInTarget()`
helper (`src/lib/analytics/bp-in-target.ts`) with one-sided ceiling
semantics + clinical hypotension floors:
@@ -73,6 +77,7 @@ also closes a maintenance trap — pre-fix any future ESH update would
need to land in 5 inline locations.
### Tests (TDD-style)
- `src/lib/analytics/__tests__/bp-in-target.test.ts` — 6 new
`isBpReadingInTarget()` cases + a regression test that **re-uses
Marc's exact production fixture** (10 paired readings → 50 % under
@@ -84,18 +89,22 @@ need to land in 5 inline locations.
/ hypotension-floor edge cases.
### Cache invalidation
Verified `measurementDependentKeys` already includes
`queryKeys.analytics()` so adding a BP measurement invalidates the
tile's TanStack Query cache (per file `src/lib/query-keys.ts`). The
0 % bug was purely calculation, not staleness.
### Commit
`577d8dd` — `fix(insights): BD-Zielbereich computes correctly for real measurement data`
## A6 — Medication-chart 7d-trend + target-range
### Changes
`src/components/charts/medication-compliance-chart.tsx`:
- New `computeMedicationTrend7d()` exported helper. Mean of recent-7
daily rates − mean of prior-7, capped at 14-day window.
Mean-difference rather than slope so a single outlier day doesn't
@@ -111,12 +120,14 @@ tile's TanStack Query cache (per file `src/lib/query-keys.ts`). The
way HealthChart's targetZone band does for BP/weight tiles.
### Tests
- `src/components/charts/__tests__/medication-compliance-chart.test.tsx`
— 6 new `computeMedicationTrend7d()` cases (empty, up, down, stable,
14-day cap, short window). Empty-state assertion that the trend
chip is suppressed when no data exists.
### Commit
`9b01c86` — `feat(charts): medication chart matches other charts (7d trend + indicator + target-range)`
## Constraint compliance
@@ -151,9 +162,11 @@ process review.
## Files touched (mine only)
Created:
- `tests/integration/bp-in-target.test.ts`
Modified:
- `src/lib/analytics/bp-in-target.ts` (centralised predicate, new export)
- `src/lib/analytics/__tests__/bp-in-target.test.ts` (new tests)
- `src/lib/insights/blood-pressure-status.ts` (use helper)
@@ -166,5 +179,6 @@ Modified:
- `src/components/charts/__tests__/medication-compliance-chart.test.tsx`
Commits on origin/main:
- `577d8dd` — A2 fix
- `9b01c86` — A6 feature (carries A7's diff inside, see race note)
+11 -5
View File
@@ -54,9 +54,10 @@ Pre-fix readings against `https://healthlog.bombeck.io/admin/api-tokens`:
Post-fix the strip still reports `scrollWidth > clientWidth` (so swipe
and keyboard-arrow scrolling stay), but with `scrollbar-width: none`
+ `::-webkit-scrollbar { display: none }` the painted bar no longer
draws. CSS-isolation probe (`/tmp/v1418-css-probe.html` +
`v1418-css-probe.mjs`) verified the rule applies cleanly.
- `::-webkit-scrollbar { display: none }` the painted bar no longer
draws. CSS-isolation probe (`/tmp/v1418-css-probe.html` +
`v1418-css-probe.mjs`) verified the rule applies cleanly.
Screenshots: `/tmp/v1418-api-tokens-prod-393x851.png` (pre-fix prod),
`/tmp/v1418-api-tokens-prod-1920x1080.png` (desktop sanity),
@@ -67,8 +68,13 @@ Screenshots: `/tmp/v1418-api-tokens-prod-393x851.png` (pre-fix prod),
`src/app/globals.css` — added a small `.no-scrollbar` utility:
```css
.no-scrollbar { scrollbar-width: none; -ms-overflow-style: none; }
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar {
scrollbar-width: none;
-ms-overflow-style: none;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
```
`src/components/admin/admin-shell.tsx` and
+2
View File
@@ -11,11 +11,13 @@ seeing them live, and replace them with a per-chart switch surface so
each overlay can be opted into independently and persists per user.
Hard NO list (memory `feedback_charts_visual_identity.md` v1.4.18):
1. Gradient area fills under chart lines.
2. Smiley/emoji glyphs on the mood chart's data points.
3. Always-on personal-baseline / mean reference line.
Hard YES list:
- Per-chart switches for "7-Tage-Trend / Trend-Pfeil / Zielbereich".
- Default state of every switch is OFF; clean line is the new default.
+5 -5
View File
@@ -7,11 +7,11 @@ Finished: 2026-05-09T23:31+02:00
## Outcomes — 3 of 3
| # | Fix | Commit |
| --- | ---------------------------------------------------------------- | ------- |
| A4 | "7-Tage-Trend" everywhere + slope30 fallback for sparse metrics | 4df6dac |
| A5 | Top-tile selector — API enum drift fixed; empty strip hidden | 93e712d |
| A8b | "All" filter chart trend — split-half mean-delta supplement | af77e5e |
| # | Fix | Commit |
| --- | --------------------------------------------------------------- | ------- |
| A4 | "7-Tage-Trend" everywhere + slope30 fallback for sparse metrics | 4df6dac |
| A5 | Top-tile selector — API enum drift fixed; empty strip hidden | 93e712d |
| A8b | "All" filter chart trend — split-half mean-delta supplement | af77e5e |
## Test deltas
+14 -7
View File
@@ -7,26 +7,30 @@ Finished: 2026-05-09T20:38+02:00
## Outcomes — 5 of 5
| # | Fix | Commit / Note |
|---|--------------------------------------------------------------------|---------------|
| 1 | BD-Zielbereich 0% bug | a967895 (bundled by sibling agent's commit; my Fix 1 work is in `src/lib/analytics/bp-in-target.ts` + 7 unit tests) |
| 2 | Medikamente graph wired to layout toggle | 73afae0 (commit message landed on a sibling agent's sidebar diff; the actual Fix 2 files — `medication-compliance-chart.tsx` + 7 tests — are in `bffdccb`) |
| 3 | Stimmung-chart auto-aggregates to weekly/monthly | 47ac14b — clean |
| 4 | "7-Tage-Schnitt" → "7-Tage-Trend" with delta indicator per metric | 4e2386e — clean |
| 5 | Top tiles independently selectable in layout settings | 8ccdfac — clean |
| # | Fix | Commit / Note |
| --- | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | BD-Zielbereich 0% bug | a967895 (bundled by sibling agent's commit; my Fix 1 work is in `src/lib/analytics/bp-in-target.ts` + 7 unit tests) |
| 2 | Medikamente graph wired to layout toggle | 73afae0 (commit message landed on a sibling agent's sidebar diff; the actual Fix 2 files — `medication-compliance-chart.tsx` + 7 tests — are in `bffdccb`) |
| 3 | Stimmung-chart auto-aggregates to weekly/monthly | 47ac14b — clean |
| 4 | "7-Tage-Schnitt" → "7-Tage-Trend" with delta indicator per metric | 4e2386e — clean |
| 5 | Top tiles independently selectable in layout settings | 8ccdfac — clean |
## Test deltas
- Baseline (start of phase): 758 tests passing, 99 files
- End of phase: 812 tests passing, 108 files
- Net: +54 unit / SSR-smoke tests across 9 new files
## Race-condition notes (for the v1.4.15 retrospective)
A1, A2, A3 ran in parallel and all wrote to the index. Two interleaving issues:
- Fix 1: my staged files (bp-in-target + test + analytics route edit) got bundled into A2's `feat(admin)` commit. Not amended (per protocol).
- Fix 2: my carefully-staged files for the medication chart got committed by A2's agent into their `fix(admin)` push, and my next `git commit` then picked up an unrelated A1 sidebar diff. Result: the commit message for Fix 2 ended up on a 3-line sidebar-nav change (`73afae0`); the Fix-2 code itself shipped under `bffdccb` ("fix(admin): make api-tokens table responsive on mobile"). Both messages are misleading but the working tree is correct.
- Fixes 3-5: I waited until after each `git pull --rebase --autostash` cycle, staged with explicit paths, and the commits landed cleanly.
## Visual / UX changes
- Tile strip: every metric tile now shows a signed 7-day delta beside the avg-7 number, coloured per metric sentiment (`up-good` green, `up-bad` orange, `neutral` muted). The label flips from "7T" / "7d" to "7T-Trend" / "7d trend" when the delta is supplied.
- Mood chart header: gains the same `bg-muted/40` 10 px uppercase chip the BP/weight charts already paint when bucketing is in effect (Weekly avg / Monatsdurchschnitt).
- Medication chart: brand-new line chart (Dracula purple) with a 80 %-target reference line, replaces the static placeholder.
@@ -34,6 +38,7 @@ A1, A2, A3 ran in parallel and all wrote to the index. Two interleaving issues:
- Settings → Dashboard: each widget row grows a second switch column. Header reads "Tile / Chart" (EN) or "Kachel / Chart" (DE).
## Files touched (excluding sibling-agent collisions)
- `src/lib/analytics/bp-in-target.ts` (new)
- `src/lib/analytics/__tests__/bp-in-target.test.ts` (new)
- `src/lib/analytics/trend-delta.ts` (new)
@@ -54,6 +59,7 @@ A1, A2, A3 ran in parallel and all wrote to the index. Two interleaving issues:
- `messages/en.json` + `messages/de.json` (added `trend7dShort`, `layoutTileColumn`, `layoutChartColumn`)
## Constraint compliance
- `pnpm test`: 812 / 812 green at every commit.
- `pnpm typecheck`: 0 errors (12 pre-existing `_param`/`_request` warnings).
- `pnpm lint`: 0 errors, same 12 warnings as baseline.
@@ -61,4 +67,5 @@ A1, A2, A3 ran in parallel and all wrote to the index. Two interleaving issues:
- A1/A2/A3/A5 ownership boundaries respected: did not touch `src/components/layout/*`, `src/proxy.ts`, `src/app/admin/**`, `src/components/admin/**`, or the `quick-add` / `mood-tile` / `onboarding-card` components substantially. Mood-tile mobile-fix race noted; A3's layout work is preserved (`mood-list.tsx` modifications are not in any of my commits).
## Deferred to v1.4.16
None for A4. All five items in scope shipped.
+3 -3
View File
@@ -18,12 +18,12 @@ Two coupled fixes for the comprehensive-insight surface:
down without redeploys, but a typo can never silently disable the
limit. Documented in `.env.example`. Unit-test pin: 10 successes
then a 429 with the message `Maximum 10 insight generations per
hour.`; same harness for the env override (3-limit smoke test) and
hour.`; same harness for the env override (3-limit smoke test) and
the fallback paths.
2. **Per-status cache invalidation.** Marc reported "stale cached
insights from hours ago" — root cause was that `/api/insights/
generate` only writes the `User.insightsCachedText` blob, while the
generate` only writes the `User.insightsCachedText` blob, while the
per-scope status routes (`general-status`, `blood-pressure-status`,
`weight-status`, `pulse-status`, `bmi-status`, `mood-status`,
`medication-compliance-status`) cache their text in `audit_logs`
@@ -64,7 +64,7 @@ tested:
1. **Explicit UTF-8 decode.** `Response.json()` defers its charset
decision to the upstream `Content-Type: application/json;
charset=…` header. An intermediate proxy that strips or rewrites
charset=…` header. An intermediate proxy that strips or rewrites
that parameter (Cloudflare-on-Cloudflare, a corporate proxy in
transit, etc.) could leave the byte stream UTF-8 while the parser
thinks it's latin-1, which is exactly the failure mode that drops
+17 -17
View File
@@ -19,14 +19,14 @@ shipped). Source of truth: `.planning/phase-A5-mobile-findings.md`.
- **Finding ID**: A5 / CRITICAL #1 (Recharts `touch-action: auto`
causing scroll-lockup).
- **Files**: `src/components/charts/{health-chart, mood-chart,
medication-compliance-chart, compliance-line-chart,
scatter-correlation-chart}.tsx`,
medication-compliance-chart, compliance-line-chart,
scatter-correlation-chart}.tsx`,
`src/components/charts/__tests__/touch-action-guard.test.ts` (new).
- **Change**: Added `touch-pan-y` Tailwind utility to every chart's
wrapping `<div>`. The two charts that previously rendered
`<ResponsiveContainer>` without an explicit wrapper (`compliance-
line-chart`, `scatter-correlation-chart`) gain a `<div className=
"touch-pan-y">` parent.
line-chart`, `scatter-correlation-chart`) gain a `<div className=
"touch-pan-y">` parent.
- **Evidence**: New textual guard test verified to fail on every
chart before the fix, then turned green after. 5 / 5 chart files
pass the contract; suite stays green at 863 tests total.
@@ -42,7 +42,7 @@ shipped). Source of truth: `.planning/phase-A5-mobile-findings.md`.
so the filter pills stop colliding with the title's count badge.
Card padding adapts `p-4 sm:p-6`. Desktop table gated behind
`hidden md:block`. New mobile card-list (`<ul data-testid="admin-
users-mobile-list" className="md:hidden">`) renders each user as
users-mobile-list" className="md:hidden">`) renders each user as
a self-contained card with all four action buttons (toggle role,
edit, reset password, force-logout) flex-wrapping below the
username + role badge + meta line. Action buttons extracted into
@@ -68,7 +68,7 @@ shipped). Source of truth: `.planning/phase-A5-mobile-findings.md`.
- A5 / HIGH `/mood` — list edit/delete sizing
- A5 / MEDIUM `/medications` — history + edit icon buttons
- **Files**: `src/components/charts/{health,mood,medication-
compliance,compliance-line,compliance}-chart{s}.tsx`,
compliance,compliance-line,compliance}-chart{s}.tsx`,
`src/components/medications/medication-card.tsx`,
`src/components/mood/mood-list.tsx`.
- **Change**: Chart range buttons `h-7 → min-h-9`; chart switches
@@ -103,27 +103,27 @@ shipped). Source of truth: `.planning/phase-A5-mobile-findings.md`.
## Deferred — picked up by future agent or v1.4.16
| Audit ID | Reason | Recommended owner |
|---|---|---|
| A5/HIGH `/insights` tab strip overflow | Cross-cutting `tabs.tsx` primitive change touches `/settings/*` + `/admin/*` too. Best done as one consolidated pass. | v1.4.16 design-systems sweep |
| A5/HIGH `/admin` tab strip overflow | Same reason — single fix for both Settings and Admin. | v1.4.16 design-systems sweep |
| A5/HIGH `/measurements` BP grouping (sys + dia → "117/79 mmHg" row) | Logic change to `measurement-list.tsx`, larger scope than tap-target sweep. Doctor-export UX impact. | v1.4.16 measurements polish |
| A5/HIGH `/measurements` hidden table DOM weight | Coupled to BP grouping fix. | v1.4.16 measurements polish |
| A5/HIGH bottom-nav 7-items → 5+More | Requires nav-IA decision (which 2 to demote — Achievements + Targets per audit, but needs Marc sign-off). | v1.4.16 nav-IA review |
| A5/MEDIUM `/admin` system-status loading-failed state | Touches `system-status-section.tsx`; B-agent-3 (notifications) and B1 (backups) are also touching admin sections. | v1.4.16 admin polish |
| A5/MEDIUM tabs h-9 default | Same `tabs.tsx` primitive — group with HIGH tab-strip wrap. | v1.4.16 |
| Audit ID | Reason | Recommended owner |
| ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | ---------------------------- |
| A5/HIGH `/insights` tab strip overflow | Cross-cutting `tabs.tsx` primitive change touches `/settings/*` + `/admin/*` too. Best done as one consolidated pass. | v1.4.16 design-systems sweep |
| A5/HIGH `/admin` tab strip overflow | Same reason — single fix for both Settings and Admin. | v1.4.16 design-systems sweep |
| A5/HIGH `/measurements` BP grouping (sys + dia → "117/79 mmHg" row) | Logic change to `measurement-list.tsx`, larger scope than tap-target sweep. Doctor-export UX impact. | v1.4.16 measurements polish |
| A5/HIGH `/measurements` hidden table DOM weight | Coupled to BP grouping fix. | v1.4.16 measurements polish |
| A5/HIGH bottom-nav 7-items → 5+More | Requires nav-IA decision (which 2 to demote — Achievements + Targets per audit, but needs Marc sign-off). | v1.4.16 nav-IA review |
| A5/MEDIUM `/admin` system-status loading-failed state | Touches `system-status-section.tsx`; B-agent-3 (notifications) and B1 (backups) are also touching admin sections. | v1.4.16 admin polish |
| A5/MEDIUM tabs h-9 default | Same `tabs.tsx` primitive — group with HIGH tab-strip wrap. | v1.4.16 |
## Cross-agent observations
- The shared-cwd race Marc documented in A2 / A4 reproduced again:
twice during this phase a `git commit` consumed a sibling agent's
staged files instead of mine. **Mitigation**: switched to `git
commit -- <pathspec>` for atomic stage-and-commit, which fixed it
commit -- <pathspec>` for atomic stage-and-commit, which fixed it
for fixes 3 / 4 / 5. Recommend the v1.4.16 marathon use the
`superpowers:using-git-worktrees` skill to give each agent its
own working tree.
- One pre-existing typecheck error in `src/lib/__tests__/dashboard-
layout.test.ts` (DashboardWidgetId narrow type vs string) is
layout.test.ts` (DashboardWidgetId narrow type vs string) is
unrelated to my work — confirmed by stashing my changes and
rerunning. Filed as v1.4.16 backlog.
- Pre-existing Playwright config + a11y dark-color-scheme work
+8 -8
View File
@@ -22,7 +22,7 @@ Roster grew from **38 → 59** (+21 achievements):
Hidden achievements render as opaque "Hidden achievement"
placeholders in the locked state — the real title, description,
metric and target are *never* written to the DOM, so a curious user
metric and target are _never_ written to the DOM, so a curious user
cannot inspect the page source to learn the trigger. Once unlocked,
the strings reveal themselves on the same card; the unlock toast
gets a longer (8 s) `<Sparkles>` celebration with the localized
@@ -37,28 +37,28 @@ so deleting data doesn't make completed achievements disappear.
## Commit walk
1. `docs(achievements): v1.4.18 expansion research with 20+
proposed achievements` — research at
proposed achievements` — research at
`.planning/v1418-achievements-research.md`.
2. `feat(achievements): definitions for v1.4.18 expanded set +
hidden Easter-eggs` — types, `ACHIEVEMENT_DEFINITIONS` list,
hidden Easter-eggs` — types, `ACHIEVEMENT_DEFINITIONS` list,
EN+DE strings, page mood/hidden category labels,
`CATEGORY_LABEL_KEY` exhaustiveness.
3. `feat(achievements): evaluators for new achievements (with
discovery rules)` — wires `buildExpansionMetricValues` +
discovery rules)` — wires `buildExpansionMetricValues` +
`applyDiscoveryFilter` into the route, fetches `MoodEntry`,
recomputes summary from the visible set.
4. `feat(achievements): page renders unlocked + locked + hidden
achievements with progress` — opaque hidden card, new lucide
achievements with progress` — opaque hidden card, new lucide
icons, page tests for the four card states + locale-localized
hidden heading.
5. `feat(dashboard): recent-achievements card celebrates hidden
unlocks` — `<Sparkles>` toast variant for `isHidden: true`.
unlocks` — `<Sparkles>` toast variant for `isHidden: true`.
6. `test(achievements): coverage for evaluators + UI states` —
13 unit assertions against `expansion-metrics.ts`, 3 integration
tests round-tripping prisma rows, e2e describe-block exercising
all four card states with leak-resistance asserts.
7. `feat(achievements): worker re-evaluates new + hidden
achievements on data change` — fixed two trigger bugs (the
achievements on data change` — fixed two trigger bugs (the
doctor-PDF action used a non-existent name; locale-flip now
emits its own `settings.locale.update` audit row when the locale
actually changes).
@@ -93,7 +93,7 @@ so deleting data doesn't make completed achievements disappear.
consumes the same filtered list — apps will see exactly what the
web sees, including hidden cards as opaque placeholders. iOS
clients can render their own "?" affordance from `isHidden: true`
+ `unlocked: false`.
- `unlocked: false`.
## Out of scope / followups
+8 -8
View File
@@ -19,18 +19,18 @@ Five atomic commits on origin/main (one cross-agent race noted):
1. **`feat(charts): reusable gradient + rich-tooltip primitives`** — landed
inside commit `2611bb4 feat(db): User.aiProviderChain for ordered
fallback config` due to a parallel-staging race with the B5b worker
fallback config` due to a parallel-staging race with the B5b worker
(B5b ran `git add -A` between my `git add` and `git commit`). My
primitives are on origin/main verbatim; the commit subject just
belongs to B5b's adjacent work.
2. **`74c2eb8 feat(charts): BP/weight/pulse polish (gradient, baseline,
rich tooltip)`** — single HealthChart wrapper covers all three vital
rich tooltip)`** — single HealthChart wrapper covers all three vital
families (BP, weight, pulse, body fat, sleep, steps).
3. **`901f44e feat(charts): mood chart polish with emoji glyphs at data
points`** — bundled with B5b's `provider-runner.ts` files (same race
points`** — bundled with B5b's `provider-runner.ts` files (same race
pattern; my charts files content correct).
4. **`8008613 feat(charts): medication chart polish (gradient +
animation + rich tooltip)`** — clean, only my 2 files.
animation + rich tooltip)`** — clean, only my 2 files.
Empty-state commit (brief item 7) was integrated into commits 2-4 since
the same `<ChartEmptyState>` primitive lands per-chart with the polish.
@@ -79,7 +79,7 @@ a target band through whenever the user-pref ships.
- ✅ Personal baseline line — new `computePersonalBaseline()` helper
(90-point rolling median, returns null for <5 points). Painted as a
faint dashed `<ReferenceLine>` labelled `"Your normal"` / `"Dein
Mittel"` (i18n keys `charts.personalBaseline`).
Mittel"` (i18n keys `charts.personalBaseline`).
- ✅ Empty / sparse-data state — new `<ChartEmptyState>` primitive
consistently used by every wrapper when daily points < 3. Lucide
`LineChart` glyph + i18n title+description (EN+DE).
@@ -103,12 +103,12 @@ a target band through whenever the user-pref ships.
Three of the four commits absorbed parallel-agent staged work:
1. Primitive files commit landed under B5b's `feat(db):
User.aiProviderChain` subject. My 6 files (`chart-gradient.tsx`,
User.aiProviderChain` subject. My 6 files (`chart-gradient.tsx`,
`chart-tooltip.tsx`, `chart-empty-state.tsx`, 3 tests, plus 7 i18n
keys EN+DE) are on origin/main inside `2611bb4` — content-verified.
2. Mood polish commit (`901f44e`) absorbed B5b's `provider-runner.ts`
+ 2 test files + `insights/generate/route.ts` modifications + AI
provider chain insights routing. My mood-chart edits are intact.
- 2 test files + `insights/generate/route.ts` modifications + AI
provider chain insights routing. My mood-chart edits are intact.
3. The mood-chart edits had to be reapplied twice after a watcher /
format-on-save process (likely a parallel agent) reverted my
in-flight `Edit` calls back to baseline mid-edit. Final form
+9 -9
View File
@@ -9,15 +9,15 @@ rebase needed — origin/main was at `c8e7639` for the entire run).
Seven atomic commits on origin/main, each TDD-first (failing tests
before implementation):
| # | Commit | What it ships |
|---|--------|---------------|
| 1 | `4d7d074 feat(insights): page hero with gradient header + generation timestamp + personal-baseline indicator` | New `<InsightsPageHero>` component renders a Dracula-gradient (purple → cyan, /15 + /8 opacity) band with sparkles glyph, h1 + overview subtitle, "Based on your last 90 days" personal-baseline caption, "Generated <relative-time>" caption, and an optional regenerate button. Pure presentational shell — `/insights/page.tsx` passes the freshest of the per-section cache timestamps in via a new `freshestUpdatedAt()` helper. 10 component tests (gradient classes, slots, EN+DE, regenerate disabled state, missing-timestamp branch). New i18n keys: `insights.heroGenerated`, `heroPersonalBaseline`, `heroRegenerate`, `heroRegenerating`, `relative{JustNow,MinutesAgo,HoursAgo,DaysAgo}` × EN+DE. Animation reuses the existing `animate-insight-in` keyframes which already gates on `prefers-reduced-motion: reduce`. |
| 2 | `9e8be4b feat(insights): recommendations grid with severity ordering + animated reveal` | New `<RecommendationsGrid>` wraps `<RecommendationCard>` (B5c-built) in a 1-col mobile / 2-col desktop CSS grid with severity-priority ordering (urgent → important → suggestion → info) via a stable `sortRecommendationsBySeverity()` helper. Each card row gets a Dracula-token left border (red/orange/purple/cyan at /70 opacity), a 200ms transition + `md:hover:-translate-y-0.5 md:hover:shadow-lg` lift, and a 100ms-staggered fade-in via `animationDelay`. Internal slots of `<RecommendationCard>` untouched per brief — the shell wraps the card. Refactored the card's outer element from `<li>` to `<div>` (the only sensible way to hoist grid/flex layout one level up without breaking valid HTML5 list semantics). `<InsightAdvisorCard>` now consumes the grid instead of its old `<ol>` + `.map`. 8 grid tests + the existing 12 advisor-card tests still green. |
| 3 | `b76351e feat(insights): polished summary typography with inline-chart sparklines` | Bumps the summary slot in `<InsightAdvisorCard>` from `text-muted-foreground text-sm` to `text-foreground/90 max-w-prose text-base leading-relaxed`. The inline-chart-token renderer (`metric:<TYPE>` allowlist from earlier phases) gains a `mini` flag; the summary surface passes `mini` so its embedded charts shrink to sparkline form (140px). Per-finding charts keep the full chart so they remain interactive. 3 new typography tests. |
| 4 | `0c287f1 feat(insights): polished loading + empty + error states` | Loading state replaces the spinner-only placeholder with a 3-card skeleton grid (`bg-muted/40-60` pulse with 100ms staggered delay) that mirrors the final layout so the page doesn't visually jump on content load. Empty state gains a centred sparkle glyph in a `bg-dracula-purple/10` circle + max-w-prose copy. Error state moves from a flat banner to a card with a discoverable `data-slot="insight-retry-button"` retry button when an `onRegenerate` handler is supplied. 4 new state tests. |
| 5 | `d2cdf9d feat(dashboard): InsightsCardPreview matches insights page visual language` | Replaces the orphan v1.4.0 `<InsightsCard>` (zero non-test imports; bound to the deprecated `changed/stable/drivers/nextSteps` schema) with a leaner `<InsightsCardPreview>` that takes an `insight: InsightResult \| null` prop and renders the top 1-2 severity-ordered recs as compact tiles + a "View all" CTA pointing at `/insights`. Same visual language as the page: severity-coloured left border, ring-variant `<ConfidenceMeter>` inline. Returns `null` when there are no recs (the dashboard's parent grid doesn't render an empty box). New i18n keys: `insights.viewAll`, `previewEmpty` × EN+DE. 8 preview tests. |
| 6 | `5063ad7 style(insights): dark-mode contrast verification + tweaks` | Bumps the page-hero gradient from `/10 + /5` to `/15 + /8` and the border from `/20` to `/25` so the band's lightness delta against the dark Dracula background sits at ~17%, clearing the 3:1 UI-element contrast bar. Skeleton placeholders go from `/30/40/50` to `/40/50/60`. Adds a regression test that pins the contrast-relevant utility classes via regex so a future refactor can't silently drop them. |
| 7 | `3b0d21e test(insights): coverage for polished page + dashboard preview` | New SSR integration suite exercises `<InsightAdvisorCard>` + `<RecommendationsGrid>` + `<RecommendationCard>` + `<ConfidenceMeter>` + `<RecommendationFeedback>` all painting together so the cross-feature slot positions don't regress (the B5c report flagged that an e2e isn't feasible because `/insights` doesn't currently mount the advisor card; the SSR test covers the same surface end-to-end). Pins severity-ordering preservation under low-confidence recs and the empty-grid unmount path. 3 integration tests. |
| # | Commit | What it ships |
| --- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | `4d7d074 feat(insights): page hero with gradient header + generation timestamp + personal-baseline indicator` | New `<InsightsPageHero>` component renders a Dracula-gradient (purple → cyan, /15 + /8 opacity) band with sparkles glyph, h1 + overview subtitle, "Based on your last 90 days" personal-baseline caption, "Generated <relative-time>" caption, and an optional regenerate button. Pure presentational shell — `/insights/page.tsx` passes the freshest of the per-section cache timestamps in via a new `freshestUpdatedAt()` helper. 10 component tests (gradient classes, slots, EN+DE, regenerate disabled state, missing-timestamp branch). New i18n keys: `insights.heroGenerated`, `heroPersonalBaseline`, `heroRegenerate`, `heroRegenerating`, `relative{JustNow,MinutesAgo,HoursAgo,DaysAgo}` × EN+DE. Animation reuses the existing `animate-insight-in` keyframes which already gates on `prefers-reduced-motion: reduce`. |
| 2 | `9e8be4b feat(insights): recommendations grid with severity ordering + animated reveal` | New `<RecommendationsGrid>` wraps `<RecommendationCard>` (B5c-built) in a 1-col mobile / 2-col desktop CSS grid with severity-priority ordering (urgent → important → suggestion → info) via a stable `sortRecommendationsBySeverity()` helper. Each card row gets a Dracula-token left border (red/orange/purple/cyan at /70 opacity), a 200ms transition + `md:hover:-translate-y-0.5 md:hover:shadow-lg` lift, and a 100ms-staggered fade-in via `animationDelay`. Internal slots of `<RecommendationCard>` untouched per brief — the shell wraps the card. Refactored the card's outer element from `<li>` to `<div>` (the only sensible way to hoist grid/flex layout one level up without breaking valid HTML5 list semantics). `<InsightAdvisorCard>` now consumes the grid instead of its old `<ol>` + `.map`. 8 grid tests + the existing 12 advisor-card tests still green. |
| 3 | `b76351e feat(insights): polished summary typography with inline-chart sparklines` | Bumps the summary slot in `<InsightAdvisorCard>` from `text-muted-foreground text-sm` to `text-foreground/90 max-w-prose text-base leading-relaxed`. The inline-chart-token renderer (`metric:<TYPE>` allowlist from earlier phases) gains a `mini` flag; the summary surface passes `mini` so its embedded charts shrink to sparkline form (140px). Per-finding charts keep the full chart so they remain interactive. 3 new typography tests. |
| 4 | `0c287f1 feat(insights): polished loading + empty + error states` | Loading state replaces the spinner-only placeholder with a 3-card skeleton grid (`bg-muted/40-60` pulse with 100ms staggered delay) that mirrors the final layout so the page doesn't visually jump on content load. Empty state gains a centred sparkle glyph in a `bg-dracula-purple/10` circle + max-w-prose copy. Error state moves from a flat banner to a card with a discoverable `data-slot="insight-retry-button"` retry button when an `onRegenerate` handler is supplied. 4 new state tests. |
| 5 | `d2cdf9d feat(dashboard): InsightsCardPreview matches insights page visual language` | Replaces the orphan v1.4.0 `<InsightsCard>` (zero non-test imports; bound to the deprecated `changed/stable/drivers/nextSteps` schema) with a leaner `<InsightsCardPreview>` that takes an `insight: InsightResult \| null` prop and renders the top 1-2 severity-ordered recs as compact tiles + a "View all" CTA pointing at `/insights`. Same visual language as the page: severity-coloured left border, ring-variant `<ConfidenceMeter>` inline. Returns `null` when there are no recs (the dashboard's parent grid doesn't render an empty box). New i18n keys: `insights.viewAll`, `previewEmpty` × EN+DE. 8 preview tests. |
| 6 | `5063ad7 style(insights): dark-mode contrast verification + tweaks` | Bumps the page-hero gradient from `/10 + /5` to `/15 + /8` and the border from `/20` to `/25` so the band's lightness delta against the dark Dracula background sits at ~17%, clearing the 3:1 UI-element contrast bar. Skeleton placeholders go from `/30/40/50` to `/40/50/60`. Adds a regression test that pins the contrast-relevant utility classes via regex so a future refactor can't silently drop them. |
| 7 | `3b0d21e test(insights): coverage for polished page + dashboard preview` | New SSR integration suite exercises `<InsightAdvisorCard>` + `<RecommendationsGrid>` + `<RecommendationCard>` + `<ConfidenceMeter>` + `<RecommendationFeedback>` all painting together so the cross-feature slot positions don't regress (the B5c report flagged that an e2e isn't feasible because `/insights` doesn't currently mount the advisor card; the SSR test covers the same surface end-to-end). Pins severity-ordering preservation under low-confidence recs and the empty-grid unmount path. 3 integration tests. |
## Verification
+16 -16
View File
@@ -11,12 +11,12 @@ Completed 2026-05-10 ~01:15 CEST on `agent/b2-ai-provider-ux` worktree.
Four atomic TDD-first commits on `agent/b2-ai-provider-ux`, ready to
fast-forward into `origin/main`:
| # | Commit | What it ships |
| --- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 1 | `feat(api): PUT /api/insights/provider-chain persists user fallback chain` | New `PUT` writes the chain to `User.aiProviderChain`. Server-side priority is recomputed from insertion order so the UI's visual contract ("first row = priority 1") cannot drift from what gets persisted. Unknown provider types and duplicates 422; an empty chain is rejected outright. `parseProviderChain()` still falls back to the default chain when the persisted JSON is malformed, so a stale-tab resubmit cannot poison generation. 5 unit tests pin the contract. |
| 2 | `feat(settings): AI provider section with single dropdown driving form below` | The big one. Single Pulldown (`ai-active-provider-select`) drives a switch-rendered config card below: Codex (connect/disconnect/status + last-insight + CODEX_MODEL note), OpenAI (API key + model select + collapsed Base URL override), Anthropic (API key + model), Local (Base URL + key + model), Admin OpenAI (read-only). Fallback chain card with up/down arrow reorder + per-row enable Switch + remove + add + reset-to-defaults. URL-synced via `?provider=…` so deep links work and the SSR test drives each branch deterministically. |
| 3 | `test(insights): integration coverage for PUT /api/insights/provider-chain` | Two scenarios pinned end-to-end against the postgres testcontainer: saving a valid reordered chain persists the JSON column with priority normalised; saving an empty chain rejects with 422 + leaves the column untouched. |
| 4 | `test(e2e): AI provider dropdown switches the rendered config form` | Playwright spec confirms the dropdown switches every config form (Codex / OpenAI / Anthropic / Local) and the fallback chain card surfaces every row with the right `data-chain-row` markers. Mocks every backend endpoint the section reads so no real keys are needed in CI. |
| # | Commit | What it ships |
| --- | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | `feat(api): PUT /api/insights/provider-chain persists user fallback chain` | New `PUT` writes the chain to `User.aiProviderChain`. Server-side priority is recomputed from insertion order so the UI's visual contract ("first row = priority 1") cannot drift from what gets persisted. Unknown provider types and duplicates 422; an empty chain is rejected outright. `parseProviderChain()` still falls back to the default chain when the persisted JSON is malformed, so a stale-tab resubmit cannot poison generation. 5 unit tests pin the contract. |
| 2 | `feat(settings): AI provider section with single dropdown driving form below` | The big one. Single Pulldown (`ai-active-provider-select`) drives a switch-rendered config card below: Codex (connect/disconnect/status + last-insight + CODEX_MODEL note), OpenAI (API key + model select + collapsed Base URL override), Anthropic (API key + model), Local (Base URL + key + model), Admin OpenAI (read-only). Fallback chain card with up/down arrow reorder + per-row enable Switch + remove + add + reset-to-defaults. URL-synced via `?provider=…` so deep links work and the SSR test drives each branch deterministically. |
| 3 | `test(insights): integration coverage for PUT /api/insights/provider-chain` | Two scenarios pinned end-to-end against the postgres testcontainer: saving a valid reordered chain persists the JSON column with priority normalised; saving an empty chain rejects with 422 + leaves the column untouched. |
| 4 | `test(e2e): AI provider dropdown switches the rendered config form` | Playwright spec confirms the dropdown switches every config form (Codex / OpenAI / Anthropic / Local) and the fallback chain card surfaces every row with the right `data-chain-row` markers. Mocks every backend endpoint the section reads so no real keys are needed in CI. |
## Acceptance criteria mapping
@@ -39,22 +39,22 @@ fast-forward into `origin/main`:
`settings.ai.activeProvider*`, `settings.ai.providerSelect.*`,
`settings.ai.providerConfigTitle`,
`settings.ai.providerChain.{title, description, moveUp, moveDown,
removeFromChain, addProvider, saveOrder, saved, saveFailed,
resetDefaults, resetConfirm*}`,
removeFromChain, addProvider, saveOrder, saved, saveFailed,
resetDefaults, resetConfirm*}`,
`settings.ai.openai.{modelSelect, modelOptionCustom,
modelCustomLabel, baseUrl*, showAdvanced, hideAdvanced, apiKey,
apiKeyPlaceholder}`,
modelCustomLabel, baseUrl*, showAdvanced, hideAdvanced, apiKey,
apiKeyPlaceholder}`,
`settings.ai.codex.{statusConnected, statusDisconnected,
statusExpired, connectButton, disconnectButton, modelSlugLabel,
modelSlugBody, lastInsight}`,
statusExpired, connectButton, disconnectButton, modelSlugLabel,
modelSlugBody, lastInsight}`,
`settings.ai.adminOpenai.{title, body, notConfigured}`,
`settings.ai.{testProvider, testSuccess, testFailedShort}`. The
i18n parity guard test (`src/lib/__tests__/i18n*.test.ts`) is green.
- **#6 Tests** — 5 new PUT route tests + 8 new component SSR tests
+ 2 integration tests + 1 Playwright spec (covers form-switch
contract + chain row markers). The existing `<AiSection>` smoke
test in `sections.test.tsx` still passes (mock now exposes
`useSearchParams`).
- 2 integration tests + 1 Playwright spec (covers form-switch
contract + chain row markers). The existing `<AiSection>` smoke
test in `sections.test.tsx` still passes (mock now exposes
`useSearchParams`).
## Verification
+5 -4
View File
@@ -9,8 +9,9 @@ verification gate.
Picked Option B (in-process sampler) over Option A (Coolify Sentinel
scrape). Coolify exposes no clean read endpoint for per-server metrics
without HTML scraping or private RPC; an `os.loadavg()` + `os.totalmem()`
+ /proc/diskstats sampler is self-contained, costs one row per minute,
and works on macOS dev as well as Linux production.
- /proc/diskstats sampler is self-contained, costs one row per minute,
and works on macOS dev as well as Linux production.
## Deliverables
@@ -43,7 +44,7 @@ and works on macOS dev as well as Linux production.
## Tests
- 8 unit tests for the sampler (env retention parsing, disk-stats null
+ throw fallbacks, create + deleteMany call shape).
- throw fallbacks, create + deleteMany call shape).
- 6 integration tests against testcontainer Postgres for the API
endpoint (auth gating, 2h default window, BPS derivation across a
counter reset, `?since=30m` preset, empty-DB happy path).
@@ -76,7 +77,7 @@ even simple `git add path/to/file && git commit` racy.
- `tests/integration/admin-host-metrics.test.ts`
- `src/components/admin/host-metrics-chart.tsx` + `__tests__/host-metrics-chart.test.tsx`
- `src/components/admin/system-status-section.tsx` (wiring)
- `messages/en.json`, `messages/de.json` (admin.hostMetrics.*)
- `messages/en.json`, `messages/de.json` (admin.hostMetrics.\*)
- `tests/integration/setup.ts` (truncate `host_metrics`)
## Did NOT touch (scope guards)
+2 -1
View File
@@ -3,6 +3,7 @@
Status: complete
Date: 2026-05-10T00:11+02:00
Commits on origin/main:
- `8ac5602` (audit-log filtering, pagination, CSV export — bundled with
the Wave-A verification-gate planning commit due to the marathon
coordinator's stash race; my code, wrong commit subject)
@@ -43,7 +44,7 @@ diagnostics.
`GET /api/admin/app-logs` reads the buffer with `traceId` / `level` /
`action` / `since` / `until` / `limit` filters, runs every event
through `redactSecrets()` on egress so a stray Bearer / hlk_ / sk-
through `redactSecrets()` on egress so a stray Bearer / hlk\_ / sk-
token in `error.message` never leaks to the admin UI; storage stays
raw to keep the diagnostic value when shipped to Loki.
+6 -6
View File
@@ -12,12 +12,12 @@ completion in the DB and offer a Settings → Account replay button.
## Acceptance criteria
| # | Criterion | Status | Commit |
| - | ----------------------------------------------------------- | ------ | ------- |
| 1 | DB migration `users.onboarding_tour_completed` | done | db5a49d |
| 2 | Tour component with spotlight + keyboard a11y | done | e57fc0a |
| 3 | Auto-launch on first dashboard load + persist on close | done | 8215e25 |
| 4 | Settings → Account → Restart tour button | done | fa1c6a6 |
| # | Criterion | Status | Commit |
| --- | ------------------------------------------------------ | ------ | ------- |
| 1 | DB migration `users.onboarding_tour_completed` | done | db5a49d |
| 2 | Tour component with spotlight + keyboard a11y | done | e57fc0a |
| 3 | Auto-launch on first dashboard load + persist on close | done | 8215e25 |
| 4 | Settings → Account → Restart tour button | done | fa1c6a6 |
## Architecture
+7 -7
View File
@@ -7,13 +7,13 @@ Completed 2026-05-10 ~00:04 CEST.
Five atomic commits on origin/main, each TDD-first (failing test, then
fix, then verification):
| # | Commit | What it ships |
|---|--------|---------------|
| 1 | `27f3933 feat(ai): curated medical-reference bundle for citation grounding` | `src/lib/ai/medical-references.ts` with 7 guidelines (ESH 2023, ESC 2024, ACC/AHA 2017, WHO 2021 hypertension, WHO BMI, DGE 2024 nutrition, AHA pulse). Each entry: stable slug, EN+DE titles, https URL, year, scope tags, metric buckets. `selectReferencesForMetrics()` filters by overlap; `getMedicalReferenceById()` lookups defensively return undefined. 15 unit tests. |
| 2 | `466b8b5 feat(ai): schema accepts validated referenceId pointing into the medical-reference bundle` | `aiRecommendationSchema.referenceId` (optional) validated via `superRefine` against `MEDICAL_REFERENCE_IDS`. Fabricated ids fail parse — defence in depth on top of the prompt instruction. `PROMPT_VERSION` bumped to `4.16.0`. 8 unit tests. |
| 3 | `7fbfca6 feat(ai): system prompt includes curated medical references for citation grounding` | New `buildSystemPromptWithReferences(locale, metrics)` injects a SOURCES block listing only references whose `metricApplicability` overlaps the current metrics, plus a GROUND RULE telling the model to cite by id. Plain `getStrictInsightsSystemPrompt(locale)` unchanged for back-compat. 10 unit tests. |
| 4 | `53aade9 feat(ai): post-validation logs citation-coverage rate per insight generation` | `src/lib/ai/citation-coverage.ts` — `detectsNormativeClaim()` (EN+DE keyword heuristic) + `computeCitationCoverage()`. The wrapper at `generate-insight.ts` calls `annotate()` with `ai_total_recommendations` / `ai_normative_recommendations` / `ai_cited_normative_recommendations` / `ai_uncited_normative_recommendation_ids` so the admin AI quality dashboard can chart coverage over time. Observational only; B5c flips to required for severity ≥ "important". 15 unit tests. |
| 5 | `a66c128 feat(insights): citation footnote on recommendations with medical reference` | `<CitationFootnote>` subcomponent in `insight-advisor-card.tsx` renders "Source: ESH 2023 — title" with `<ExternalLink>` icon, opens in a new tab via `target="_blank" rel="noreferrer"`. `InsightResult.recommendations` widens to a union (legacy string OR `{ text, referenceId? }`) so existing render paths keep working. New i18n keys `insights.recommendation.source` + `insights.recommendation.viewSource` in EN+DE. 6 component tests. |
| # | Commit | What it ships |
| --- | --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | `27f3933 feat(ai): curated medical-reference bundle for citation grounding` | `src/lib/ai/medical-references.ts` with 7 guidelines (ESH 2023, ESC 2024, ACC/AHA 2017, WHO 2021 hypertension, WHO BMI, DGE 2024 nutrition, AHA pulse). Each entry: stable slug, EN+DE titles, https URL, year, scope tags, metric buckets. `selectReferencesForMetrics()` filters by overlap; `getMedicalReferenceById()` lookups defensively return undefined. 15 unit tests. |
| 2 | `466b8b5 feat(ai): schema accepts validated referenceId pointing into the medical-reference bundle` | `aiRecommendationSchema.referenceId` (optional) validated via `superRefine` against `MEDICAL_REFERENCE_IDS`. Fabricated ids fail parse — defence in depth on top of the prompt instruction. `PROMPT_VERSION` bumped to `4.16.0`. 8 unit tests. |
| 3 | `7fbfca6 feat(ai): system prompt includes curated medical references for citation grounding` | New `buildSystemPromptWithReferences(locale, metrics)` injects a SOURCES block listing only references whose `metricApplicability` overlaps the current metrics, plus a GROUND RULE telling the model to cite by id. Plain `getStrictInsightsSystemPrompt(locale)` unchanged for back-compat. 10 unit tests. |
| 4 | `53aade9 feat(ai): post-validation logs citation-coverage rate per insight generation` | `src/lib/ai/citation-coverage.ts` — `detectsNormativeClaim()` (EN+DE keyword heuristic) + `computeCitationCoverage()`. The wrapper at `generate-insight.ts` calls `annotate()` with `ai_total_recommendations` / `ai_normative_recommendations` / `ai_cited_normative_recommendations` / `ai_uncited_normative_recommendation_ids` so the admin AI quality dashboard can chart coverage over time. Observational only; B5c flips to required for severity ≥ "important". 15 unit tests. |
| 5 | `a66c128 feat(insights): citation footnote on recommendations with medical reference` | `<CitationFootnote>` subcomponent in `insight-advisor-card.tsx` renders "Source: ESH 2023 — title" with `<ExternalLink>` icon, opens in a new tab via `target="_blank" rel="noreferrer"`. `InsightResult.recommendations` widens to a union (legacy string OR `{ text, referenceId? }`) so existing render paths keep working. New i18n keys `insights.recommendation.source` + `insights.recommendation.viewSource` in EN+DE. 6 component tests. |
## Verification
+5 -5
View File
@@ -6,12 +6,12 @@ Completed 2026-05-10 ~00:40 CEST.
Four atomic commits on origin/main, each TDD-first (failing test then fix):
| # | Commit | What it ships |
| --- | ------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | `2611bb4 feat(db): User.aiProviderChain for ordered fallback config` | New `User.aiProviderChain` JSONB column + migration `0033_user_ai_provider_chain`. `parseProviderChain()` defensively coerces malformed input back to `PROVIDER_CHAIN_DEFAULT` (codex → openai → anthropic → local → admin-openai). 15 unit tests. Credentials still live in the existing AES-256-GCM columns; the chain only metadata. |
| # | Commit | What it ships |
| --- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | `2611bb4 feat(db): User.aiProviderChain for ordered fallback config` | New `User.aiProviderChain` JSONB column + migration `0033_user_ai_provider_chain`. `parseProviderChain()` defensively coerces malformed input back to `PROVIDER_CHAIN_DEFAULT` (codex → openai → anthropic → local → admin-openai). 15 unit tests. Credentials still live in the existing AES-256-GCM columns; the chain only metadata. |
| 2 | `901f44e feat(charts): mood chart polish ...` (cross-agent race captured the actual subject) — bundled my B5b runner + route work | `src/lib/ai/provider-runner.ts` exports `runWithFallback()` (strict-schema variant, used when the route migrates to `generateInsight()` in B5c) AND `runRawCompletionWithFallback()` (legacy-shape variant the current route consumes). Hard-fail policy: 401/403/429/5xx/transport cascade; schema 422 bubbles. Last-working-provider cache: 1h TTL, in-process per-userId. Five `ai_chain_*` Wide-Event annotations per generation. |
| 3 | `613d661 feat(settings): minimal display of active AI provider + configured chain` | New `GET /api/insights/provider-chain` returns `{ activeProvider, cachedActiveProvider, configuredChain }`. `<ProviderChainSummary>` subcomponent in `<AiSection>` renders "Active provider: ChatGPT (Codex)" + "Configured chain: ..." with a "(cached: …)" suffix when the runner has rerouted. EN+DE i18n keys under `settings.ai.providerChain.*`. Hides itself when no provider is configured. |
| 4 | `d2bda42 test(ai): provider fallback covers happy path, hard-fail cascade, cache` | Integration test against the postgres testcontainer with two user-level providers (openai + anthropic) and a fetch-spy intercept routing on URL prefix. Three scenarios pinned: hard-fail-then-success cascade, all-fail cascade, and last-working-cache reorder. |
| 3 | `613d661 feat(settings): minimal display of active AI provider + configured chain` | New `GET /api/insights/provider-chain` returns `{ activeProvider, cachedActiveProvider, configuredChain }`. `<ProviderChainSummary>` subcomponent in `<AiSection>` renders "Active provider: ChatGPT (Codex)" + "Configured chain: ..." with a "(cached: …)" suffix when the runner has rerouted. EN+DE i18n keys under `settings.ai.providerChain.*`. Hides itself when no provider is configured. |
| 4 | `d2bda42 test(ai): provider fallback covers happy path, hard-fail cascade, cache` | Integration test against the postgres testcontainer with two user-level providers (openai + anthropic) and a fetch-spy intercept routing on URL prefix. Three scenarios pinned: hard-fail-then-success cascade, all-fail cascade, and last-working-cache reorder. |
## Verification
+10 -10
View File
@@ -7,16 +7,16 @@ Completed 2026-05-10 ~01:17 CEST.
Eight atomic commits on origin/main (worktree: `agent/b5c-explainability`,
clean rebase + push to `main`), each TDD-first (failing test, then fix):
| # | Commit | What it ships |
|---|--------|---------------|
| 1 | `8a438a0 feat(ai): schema requires rationale (window + comparedTo + deviation) per recommendation` | New `aiRecommendationRationaleSchema` + mandatory `rationale` field on `aiRecommendationSchema`. Empty `comparedTo` / `deviation` rejected; `dataWindow` enum locked to the 4-window vocabulary. New `findRecommendationsMissingRationale()` for legacy-payload detection. 10 unit tests + 5 fixture-touch updates across `generate-insight.test.ts`, `reference-id-schema.test.ts`, `citation-enforcement.test.ts`, `citation-coverage-logging.test.ts`. |
| 2 | `c39a527 feat(ai): system prompt requires rationale per recommendation` | EN+DE prompt advertises the new field shape AND a fifth GROUND RULE mandating rationale on every rec. `rationale.dataWindow MUST equal metricSource.timeRange` is enforced at prompt-time so the mini-chart pinning is bracket-mismatch-free. PROMPT_VERSION stays at 4.16.0 (B5a already bumped). 6 prompt-assertion tests. |
| 3 | `13f1ae5 feat(ai): corrective retry covers missing rationale fields` | The wrapper's `buildRetryCorrectionMessage()` now lists the three rationale fields explicitly so a first-attempt response without rationale gets a targeted reprompt. Three new `ai_rationale_*` Wide-Event keys land alongside the existing citation-coverage annotations so the admin AI dashboard can chart rationale-coverage per generation. 4 unit tests. |
| 4 | `c8b30c1 feat(ai): legacy insight payload detection with regenerate CTA` | New `isLegacyInsightPayload()` helper + route wiring: `/api/insights/generate` returns `legacyPayload: true` on cache-hit when the cached blob predates B5c rationale. UI shows a one-time "Insights updated — regenerate for new explainability features" CTA pointing at the regenerate button. User-initiated only; no auto-regen on cache-hit (would burn rate-limit tokens silently). 7 unit tests. |
| 5 | `7f54c0c feat(charts): mini-mode + windowOverride prop for embedded rationale charts` | HealthChart gains `mini` (drops range tabs + toggle row, h-[140px]) and `windowOverride` (pins to a rationale enum value). New pure helper `resolveMiniRangePoints()` (`mini-window.ts`) maps the rationale enum onto the existing per-points TIME_RANGES_KEYS. 7 unit tests. |
| 6 | `680f84c feat(charts): mood chart mini-mode + windowOverride` | MoodChart mirrors the same `mini` + `windowOverride` contract so mood-typed recommendations get a dedicated emoji-glyph mini-chart. |
| 7 | `10a67ff feat(insights): RecommendationCard with expandable rationale + mini-chart of data window` | New `<RecommendationCard>` subcomponent with severity badge, default-collapsed chevron, animated 200ms expand reveal, 3-row `<RationaleCard>` (Window / Compared to / Deviation), pinned mini-chart (HealthChart for measurement metrics, MoodChart for mood-keyed recs), citation footnote, and named slots for B5d (`data-slot="rec-confidence-slot"`) and B5e (`data-slot="rec-feedback-slot"`). InsightAdvisorCard refactored to consume it; `legacyPayload` prop surfaces the regenerate CTA banner. InsightRecommendation type widens to optionally carry rationale + metricSource + severity + id. EN+DE i18n keys: `rationale`, `rationaleWindow`, `rationaleComparedTo`, `rationaleDeviation`, `rationaleExpand`, `rationaleCollapse`, `legacyPayloadCta`. 11 component tests. |
| 8 | `fed2e7e test(insights): coverage for RecommendationCard expand + rationale rendering` | Full-flow integration test: MockAIProvider → `generateInsight()` → InsightAdvisorCard → SSR markup assertion. Second case pins the legacyPayload CTA path with a string-only recommendations[] payload. 2 tests. |
| # | Commit | What it ships |
| --- | -------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | `8a438a0 feat(ai): schema requires rationale (window + comparedTo + deviation) per recommendation` | New `aiRecommendationRationaleSchema` + mandatory `rationale` field on `aiRecommendationSchema`. Empty `comparedTo` / `deviation` rejected; `dataWindow` enum locked to the 4-window vocabulary. New `findRecommendationsMissingRationale()` for legacy-payload detection. 10 unit tests + 5 fixture-touch updates across `generate-insight.test.ts`, `reference-id-schema.test.ts`, `citation-enforcement.test.ts`, `citation-coverage-logging.test.ts`. |
| 2 | `c39a527 feat(ai): system prompt requires rationale per recommendation` | EN+DE prompt advertises the new field shape AND a fifth GROUND RULE mandating rationale on every rec. `rationale.dataWindow MUST equal metricSource.timeRange` is enforced at prompt-time so the mini-chart pinning is bracket-mismatch-free. PROMPT_VERSION stays at 4.16.0 (B5a already bumped). 6 prompt-assertion tests. |
| 3 | `13f1ae5 feat(ai): corrective retry covers missing rationale fields` | The wrapper's `buildRetryCorrectionMessage()` now lists the three rationale fields explicitly so a first-attempt response without rationale gets a targeted reprompt. Three new `ai_rationale_*` Wide-Event keys land alongside the existing citation-coverage annotations so the admin AI dashboard can chart rationale-coverage per generation. 4 unit tests. |
| 4 | `c8b30c1 feat(ai): legacy insight payload detection with regenerate CTA` | New `isLegacyInsightPayload()` helper + route wiring: `/api/insights/generate` returns `legacyPayload: true` on cache-hit when the cached blob predates B5c rationale. UI shows a one-time "Insights updated — regenerate for new explainability features" CTA pointing at the regenerate button. User-initiated only; no auto-regen on cache-hit (would burn rate-limit tokens silently). 7 unit tests. |
| 5 | `7f54c0c feat(charts): mini-mode + windowOverride prop for embedded rationale charts` | HealthChart gains `mini` (drops range tabs + toggle row, h-[140px]) and `windowOverride` (pins to a rationale enum value). New pure helper `resolveMiniRangePoints()` (`mini-window.ts`) maps the rationale enum onto the existing per-points TIME_RANGES_KEYS. 7 unit tests. |
| 6 | `680f84c feat(charts): mood chart mini-mode + windowOverride` | MoodChart mirrors the same `mini` + `windowOverride` contract so mood-typed recommendations get a dedicated emoji-glyph mini-chart. |
| 7 | `10a67ff feat(insights): RecommendationCard with expandable rationale + mini-chart of data window` | New `<RecommendationCard>` subcomponent with severity badge, default-collapsed chevron, animated 200ms expand reveal, 3-row `<RationaleCard>` (Window / Compared to / Deviation), pinned mini-chart (HealthChart for measurement metrics, MoodChart for mood-keyed recs), citation footnote, and named slots for B5d (`data-slot="rec-confidence-slot"`) and B5e (`data-slot="rec-feedback-slot"`). InsightAdvisorCard refactored to consume it; `legacyPayload` prop surfaces the regenerate CTA banner. InsightRecommendation type widens to optionally carry rationale + metricSource + severity + id. EN+DE i18n keys: `rationale`, `rationaleWindow`, `rationaleComparedTo`, `rationaleDeviation`, `rationaleExpand`, `rationaleCollapse`, `legacyPayloadCta`. 11 component tests. |
| 8 | `fed2e7e test(insights): coverage for RecommendationCard expand + rationale rendering` | Full-flow integration test: MockAIProvider → `generateInsight()` → InsightAdvisorCard → SSR markup assertion. Second case pins the legacyPayload CTA path with a string-only recommendations[] payload. 2 tests. |
## Verification
+8 -8
View File
@@ -10,14 +10,14 @@ Six atomic commits on origin/main (worktree:
`pnpm test` 1457/1457, `pnpm test:integration` 59/59,
`pnpm typecheck` 0 errors, `pnpm lint` 12 pre-existing warnings.
| # | Commit | What it ships |
|---|--------|---------------|
| 1 | `0cb0373 feat(ai): deterministic confidence-score computation per recommendation` | New `aiRecommendationSchema.confidence: z.number().int().min(0).max(100).optional()`. Mirror field on the UI-side `insightRecommendationSchema`. NEW `src/lib/ai/confidence.ts` — pure `computeConfidence({ n, recencyDays, deviationStdRatio })` returning 0..100. Score: log-saturating n curve (cap 40, n<3 hard-cap 5*n) + linear recency decay (30 at <2d → 0 at 30d) + signal contribution (30 at \|z\|≥1.5, scaled to 0; null → neutral 15). 15 tests cover the input matrix per research §2.C plus monotonicity-in-n + integer-in-bounds invariants. |
| 2 | `af21d4d feat(ai): wrapper overrides recommendation confidence with deterministic computation` | `generateInsight()` post-validation step OVERWRITES `rec.confidence` with `computeConfidence()` for every rec. The model's claimed value is discarded. New optional `options.confidenceContext` resolver lets a route layer supply per-rec `ConfidenceInputs` derived from `metricSource.n` + bucket-series freshest bucket + 90-day stdev; default fallback is `{ n: metricSource.n ?? 0, recencyDays: 0, deviationStdRatio: null }` so the override fires regardless. Wide-Event annotation `ai_confidence_override_*` carries per-rec `{id, model, computed}` triples for admin observability. 6 wrapper tests pin: model 99 → server 65 (default fallback), wrapper-fills when absent, resolver path overrides, n<3 hard-cap, missing `metricSource.n` → n=0 floor, resolver receives each rec in payload order. Citation-coverage test relaxed to find call by meta-key (annotate is now called twice). |
| 3 | `7ec1030 feat(insights): ConfidenceMeter component (bars + ring + draft pill below 25)` | NEW `<ConfidenceMeter value={n} variant="bars\|ring">`. Bars variant: 5 bars rising in height (4/6/8/10/12 px) so the "more is more" cue works without color (a11y). Ring variant: 28×28 SVG with stroke-dasharray fill. Color bands: green ≥80, yellow 50-79, orange 25-49; below 25 a red "Draft" pill REPLACES the meter (Apple's quiet-when-unsure affordance per research §2.A). aria-label always announces numeric score in EN/DE. NaN/-1 → 0 (draft); >100 → 100 (high). 5 i18n keys per locale: confidence + confidenceAria + confidenceHigh/Medium/Low/Draft. 17 component tests cover all 5 bands + both variants + EN/DE aria-label + clamp. |
| 4 | `63cfd8e feat(insights): RecommendationCard renders ConfidenceMeter in named slot` | Fills `data-slot="rec-confidence-slot"` with `<ConfidenceMeter value={rec.confidence} />`. Slot stays empty when confidence is undefined (legacy payload). Below 50, expanded rationale card surfaces "Low confidence — based on limited data" caption. Below 25 caption still applies because draft <= low. B5e's feedback-thumbs slot left untouched. Threshold is named const (`LOW_CONFIDENCE_CAPTION_THRESHOLD = 50`). 8 wiring tests. |
| 5 | `d343ab5 test(insights): coverage for confidence computation + meter rendering` | Full-flow integration test: MockAIProvider returns model=99 → wrapper overrides with computed=65 (n=9, recency=0, ratio=null) → InsightAdvisorCard renders the medium/yellow band, NOT high/green. Second case pins n=1 → draft band end-to-end. 2 tests. |
| 6 | `173c3e1 test(insights): rec-card-confidence test plays nice with B5e thumbs` | Cross-agent race fix: B5e wired RecommendationFeedback into the rec card concurrently. My test got the same useAuth + useMutation + tanstack-query SSR mocks the existing `recommendation-card.test.tsx` carries; the "feedback-slot is empty" assertion relaxed to "feedback-slot is still rendered" since B5e legitimately fills it. |
| # | Commit | What it ships |
| --- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 1 | `0cb0373 feat(ai): deterministic confidence-score computation per recommendation` | New `aiRecommendationSchema.confidence: z.number().int().min(0).max(100).optional()`. Mirror field on the UI-side `insightRecommendationSchema`. NEW `src/lib/ai/confidence.ts` — pure `computeConfidence({ n, recencyDays, deviationStdRatio })` returning 0..100. Score: log-saturating n curve (cap 40, n<3 hard-cap 5\*n) + linear recency decay (30 at <2d → 0 at 30d) + signal contribution (30 at \|z\|≥1.5, scaled to 0; null → neutral 15). 15 tests cover the input matrix per research §2.C plus monotonicity-in-n + integer-in-bounds invariants. |
| 2 | `af21d4d feat(ai): wrapper overrides recommendation confidence with deterministic computation` | `generateInsight()` post-validation step OVERWRITES `rec.confidence` with `computeConfidence()` for every rec. The model's claimed value is discarded. New optional `options.confidenceContext` resolver lets a route layer supply per-rec `ConfidenceInputs` derived from `metricSource.n` + bucket-series freshest bucket + 90-day stdev; default fallback is `{ n: metricSource.n ?? 0, recencyDays: 0, deviationStdRatio: null }` so the override fires regardless. Wide-Event annotation `ai_confidence_override_*` carries per-rec `{id, model, computed}` triples for admin observability. 6 wrapper tests pin: model 99 → server 65 (default fallback), wrapper-fills when absent, resolver path overrides, n<3 hard-cap, missing `metricSource.n` → n=0 floor, resolver receives each rec in payload order. Citation-coverage test relaxed to find call by meta-key (annotate is now called twice). |
| 3 | `7ec1030 feat(insights): ConfidenceMeter component (bars + ring + draft pill below 25)` | NEW `<ConfidenceMeter value={n} variant="bars\|ring">`. Bars variant: 5 bars rising in height (4/6/8/10/12 px) so the "more is more" cue works without color (a11y). Ring variant: 28×28 SVG with stroke-dasharray fill. Color bands: green ≥80, yellow 50-79, orange 25-49; below 25 a red "Draft" pill REPLACES the meter (Apple's quiet-when-unsure affordance per research §2.A). aria-label always announces numeric score in EN/DE. NaN/-1 → 0 (draft); >100 → 100 (high). 5 i18n keys per locale: confidence + confidenceAria + confidenceHigh/Medium/Low/Draft. 17 component tests cover all 5 bands + both variants + EN/DE aria-label + clamp. |
| 4 | `63cfd8e feat(insights): RecommendationCard renders ConfidenceMeter in named slot` | Fills `data-slot="rec-confidence-slot"` with `<ConfidenceMeter value={rec.confidence} />`. Slot stays empty when confidence is undefined (legacy payload). Below 50, expanded rationale card surfaces "Low confidence — based on limited data" caption. Below 25 caption still applies because draft <= low. B5e's feedback-thumbs slot left untouched. Threshold is named const (`LOW_CONFIDENCE_CAPTION_THRESHOLD = 50`). 8 wiring tests. |
| 5 | `d343ab5 test(insights): coverage for confidence computation + meter rendering` | Full-flow integration test: MockAIProvider returns model=99 → wrapper overrides with computed=65 (n=9, recency=0, ratio=null) → InsightAdvisorCard renders the medium/yellow band, NOT high/green. Second case pins n=1 → draft band end-to-end. 2 tests. |
| 6 | `173c3e1 test(insights): rec-card-confidence test plays nice with B5e thumbs` | Cross-agent race fix: B5e wired RecommendationFeedback into the rec card concurrently. My test got the same useAuth + useMutation + tanstack-query SSR mocks the existing `recommendation-card.test.tsx` carries; the "feedback-slot is empty" assertion relaxed to "feedback-slot is still rendered" since B5e legitimately fills it. |
## Cross-agent race notes
+8 -8
View File
@@ -8,14 +8,14 @@ commits on origin/main, each TDD-first (failing test then fix).
## What landed
| # | Commit | What it ships |
|---|--------|---------------|
| 1 | `badc893 feat(db): RecommendationFeedback model with GDPR cascade + provider attribution` | Prisma model + migration `0034_recommendation_feedback`. `(userId, recommendationId, recommendationText)` unique constraint as the dedup key; `(userId, createdAt)` and `(severity, helpful, createdAt)` indexes for the per-user timeline + aggregator slice. Adds `AppSettings.adminAiInsightsFeedbackSummary Json?` for the daily aggregator's output. Cascade-delete integration test seeds + asserts a feedback row. |
| 2 | `8255e3e feat(api): /api/insights/feedback persists thumbs with provider attribution` | New `POST /api/insights/feedback` route: zod-validated body, `withIdempotency()` retry-replay, `requireAuth()` gate, server-fills providerType + promptVersion via `resolveFeedbackAttribution()` (reads latest `insights.generate` audit row, prefers chainProviderType > providerType > "unknown"). 201 on insert / 409 on dedup / 422 on validation. Audit row `insights.recommendation.feedback`. 7 schema tests + 5 attribution tests + 4 integration tests. |
| 3 | `ca84fb7 feat(insights): thumbs-up/down feedback component with optimistic updates` | `<RecommendationFeedback>` component — TanStack Query mutation, two icon buttons (thumb-up / thumb-down), state machine (default → submitting → submitted/already-rated/error). localStorage cache keyed `(userId, recId, truncatedRecText)`. Auth-resolution race handled via setState-during-render (not useEffect — honours `react-hooks/set-state-in-effect`). EN+DE i18n: 5 new keys under `insights.recommendation.{feedbackHelpful,feedbackNotHelpful,feedbackThanks,feedbackConfirmed,feedbackAlreadyRated}`. 6 component tests. |
| 4 | `47ca1e4 feat(insights): RecommendationCard renders feedback thumbs in named slot` | Fills the `data-slot="rec-feedback-slot"` reserved by B5c. Defence-in-depth `asFeedbackTimeRange()` + attribute completeness check leaves the slot empty for legacy/partial recs (no id, missing severity, out-of-vocabulary timeRange) so a thumbs-click can never produce a 422. 3 new rec-card tests + retrofit `useMutation` + `useAuth` mocks across the existing rec-card / advisor-card / integration test files. |
| 5 | `71ffe30 feat(jobs): daily feedback aggregator writes per-(severity x provider) summary to AppSettings` | New pg-boss `feedback-aggregator` queue running daily 04:00 Europe/Berlin (after the rate-limit / idempotency / audit-log cleanups). Pure `buildFeedbackBuckets()` helper for in-memory aggregation; `aggregateRecommendationFeedback()` does the Prisma round-trip + AppSettings upsert. Bucket sort is deterministic (severity → metricSourceType → provider → prompt). Worker handler degrades gracefully on failure (warn, don't throw). 5 unit tests cover bucketing + 2 aggregator scenarios. |
| 6 | `8879282 feat(admin): AI quality preview showing per-(severity x provider) helpful-rate` | New `/admin/ai-quality` route + `<AiQualitySection>` component reading `GET /api/admin/ai-quality` (admin-only). 7-column table tinted by severity + helpful-rate band (green ≥ 0.8 / yellow ≥ 0.5 / orange below). New `ai-quality` slug threaded through ADMIN_SECTION_SLUGS + admin-shell + dynamic [section] renderer. Empty-state copy distinguishes "no feedback yet" from "all-zeros". EN+DE i18n: 13 new keys (`admin.section.ai-quality.*` + `admin.aiQuality.*`). 5 dedicated component tests + 1 sections-smoke + 1 slug-parity test. |
| # | Commit | What it ships |
| --- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 1 | `badc893 feat(db): RecommendationFeedback model with GDPR cascade + provider attribution` | Prisma model + migration `0034_recommendation_feedback`. `(userId, recommendationId, recommendationText)` unique constraint as the dedup key; `(userId, createdAt)` and `(severity, helpful, createdAt)` indexes for the per-user timeline + aggregator slice. Adds `AppSettings.adminAiInsightsFeedbackSummary Json?` for the daily aggregator's output. Cascade-delete integration test seeds + asserts a feedback row. |
| 2 | `8255e3e feat(api): /api/insights/feedback persists thumbs with provider attribution` | New `POST /api/insights/feedback` route: zod-validated body, `withIdempotency()` retry-replay, `requireAuth()` gate, server-fills providerType + promptVersion via `resolveFeedbackAttribution()` (reads latest `insights.generate` audit row, prefers chainProviderType > providerType > "unknown"). 201 on insert / 409 on dedup / 422 on validation. Audit row `insights.recommendation.feedback`. 7 schema tests + 5 attribution tests + 4 integration tests. |
| 3 | `ca84fb7 feat(insights): thumbs-up/down feedback component with optimistic updates` | `<RecommendationFeedback>` component — TanStack Query mutation, two icon buttons (thumb-up / thumb-down), state machine (default → submitting → submitted/already-rated/error). localStorage cache keyed `(userId, recId, truncatedRecText)`. Auth-resolution race handled via setState-during-render (not useEffect — honours `react-hooks/set-state-in-effect`). EN+DE i18n: 5 new keys under `insights.recommendation.{feedbackHelpful,feedbackNotHelpful,feedbackThanks,feedbackConfirmed,feedbackAlreadyRated}`. 6 component tests. |
| 4 | `47ca1e4 feat(insights): RecommendationCard renders feedback thumbs in named slot` | Fills the `data-slot="rec-feedback-slot"` reserved by B5c. Defence-in-depth `asFeedbackTimeRange()` + attribute completeness check leaves the slot empty for legacy/partial recs (no id, missing severity, out-of-vocabulary timeRange) so a thumbs-click can never produce a 422. 3 new rec-card tests + retrofit `useMutation` + `useAuth` mocks across the existing rec-card / advisor-card / integration test files. |
| 5 | `71ffe30 feat(jobs): daily feedback aggregator writes per-(severity x provider) summary to AppSettings` | New pg-boss `feedback-aggregator` queue running daily 04:00 Europe/Berlin (after the rate-limit / idempotency / audit-log cleanups). Pure `buildFeedbackBuckets()` helper for in-memory aggregation; `aggregateRecommendationFeedback()` does the Prisma round-trip + AppSettings upsert. Bucket sort is deterministic (severity → metricSourceType → provider → prompt). Worker handler degrades gracefully on failure (warn, don't throw). 5 unit tests cover bucketing + 2 aggregator scenarios. |
| 6 | `8879282 feat(admin): AI quality preview showing per-(severity x provider) helpful-rate` | New `/admin/ai-quality` route + `<AiQualitySection>` component reading `GET /api/admin/ai-quality` (admin-only). 7-column table tinted by severity + helpful-rate band (green ≥ 0.8 / yellow ≥ 0.5 / orange below). New `ai-quality` slug threaded through ADMIN_SECTION_SLUGS + admin-shell + dynamic [section] renderer. Empty-state copy distinguishes "no feedback yet" from "all-zeros". EN+DE i18n: 13 new keys (`admin.section.ai-quality.*` + `admin.aiQuality.*`). 5 dedicated component tests + 1 sections-smoke + 1 slug-parity test. |
## Verification
+3 -3
View File
@@ -11,9 +11,9 @@ Status: complete on origin/main.
`data-testid` so the e2e suite can target it without depending on the
localised label.
- New dynamic-route slug `/settings/export` wired through `SETTINGS_SECTION_SLUGS`
+ `SECTION_COMPONENTS` + `SETTINGS_SECTIONS` (sidebar nav + mobile strip).
`<SectionPlaceholder>` learned the new icon so a future slug addition
cannot crash type-checking.
- `SECTION_COMPONENTS` + `SETTINGS_SECTIONS` (sidebar nav + mobile strip).
`<SectionPlaceholder>` learned the new icon so a future slug addition
cannot crash type-checking.
- `<AdvancedSection>` simplified to the danger-zone card alone — every
export path moved out into `<ExportSection>`. The "wipe all data"
surface should never live next to a one-click export button.
+13 -9
View File
@@ -14,6 +14,7 @@ Four atomic TDD-first commits on origin/main (`43204cb..f9f99ea`):
4. `f9f99ea feat(insights): AI narrates comparison when toggle is active`
### 1. Toggle + persistence
The "Compare to" Select pulldown lives at the top of `Settings → Dashboard`
(`src/components/settings/dashboard-layout-section.tsx`), driven by
`COMPARISON_BASELINES = ["none", "lastMonth", "lastYear"]`. The
@@ -24,6 +25,7 @@ The PUT schema accepts the new optional field; pre-B8 clients keep
PUTing without it. 4 new unit tests in `dashboard-layout.test.ts`.
### 2. Chart overlay
HealthChart and MoodChart accept a `compareBaseline` prop. When set,
a parallel daily series is computed via `shiftDailySeriesForward()`
(new pure helper at `src/lib/charts/comparison-shift.ts`, 11 unit
@@ -39,6 +41,7 @@ adds a second row with the prior absolute value. 2 SSR tests in
`health-chart-comparison.test.tsx`.
### 3. Tile delta callout
DataSummary gains `avg30LastMonth` / `avg30LastYear` (30-day means
over `[30, 60)` and `[365, 395)` day windows). Every metric the
analytics API summarises gets these for free — no extra DB queries.
@@ -49,6 +52,7 @@ below the latest value, colour-coded via the existing
no horizontal scroll. 6 SSR tests in `trend-card-comparison.test.tsx`.
### 4. AI narrative
`buildUserPrompt()` accepts an optional `ComparisonSnapshot`. When
supplied it appends a SYSTEM CONTEXT block listing each metric's
current 30d avg, baseline 30d avg, absolute delta, and percentage
@@ -64,15 +68,15 @@ match exactly what the tiles render. 8 new unit tests in
## Acceptance-criteria coverage
| # | Criterion | Status |
| - | --------- | ------ |
| 1 | Toggle + persistence | shipped (commit 1) |
| 2 | Chart overlay rendering | shipped (commit 2) |
| 3 | Tile delta callout | shipped (commit 3) |
| 4 | Insights narrative integration | shipped — prompt block + ground rule (commit 4) |
| 5 | Dedicated `/insights/compare` page | DEFERRED to v1.4.17 |
| 6 | i18n EN+DE | shipped — 14 new key paths under `comparison.*` |
| 7 | Tests | shipped — 27 new tests (4 layout + 11 shift helper + 2 chart SSR + 6 tile SSR + 8 prompt) |
| # | Criterion | Status |
| --- | ---------------------------------- | ----------------------------------------------------------------------------------------- |
| 1 | Toggle + persistence | shipped (commit 1) |
| 2 | Chart overlay rendering | shipped (commit 2) |
| 3 | Tile delta callout | shipped (commit 3) |
| 4 | Insights narrative integration | shipped — prompt block + ground rule (commit 4) |
| 5 | Dedicated `/insights/compare` page | DEFERRED to v1.4.17 |
| 6 | i18n EN+DE | shipped — 14 new key paths under `comparison.*` |
| 7 | Tests | shipped — 27 new tests (4 layout + 11 shift helper + 2 chart SSR + 6 tile SSR + 8 prompt) |
## What's deferred to v1.4.17
+19 -19
View File
@@ -24,7 +24,7 @@ commits rebased + pushed to `origin/main` directly.
`API_TOKEN_HMAC_KEY` and `SESSION_SECRET` for symmetry + future-
proofing.
- **Evidence:** `gh run list --workflow=integration.yml --status=success
--limit 5` returned 5 successes after the fix landed.
--limit 5` returned 5 successes after the fix landed.
### e2e (`e2e.yml`)
@@ -33,7 +33,7 @@ commits rebased + pushed to `origin/main` directly.
- **Commits:**
- `fbcd106 fix(ci): integration tests + e2e workflows green again`
- `00848b7 fix(e2e): skip desktop-sidebar specs on mobile + stub
/api/dashboard/widgets`
/api/dashboard/widgets`
- **Root cause #1 (5 of 9 fails):** `e2e/setup/global-setup.ts`
seeded the deterministic e2e user with
`onboarding_tour_completed = false` (default). Every authenticated
@@ -59,7 +59,7 @@ commits rebased + pushed to `origin/main` directly.
### H1 — admin restore-failed scrubs raw Prisma error
- **Commit:** `fdac9e2 fix(admin): scrub raw Prisma error from
restore-failed response`.
restore-failed response`.
- **Files:** `src/app/api/admin/backups/[id]/restore/route.ts:378-393`.
- The catch block now returns a stable `"Restore failed"` 500
instead of `"Restore transaction failed: <verbose-prisma-text>"`.
@@ -69,7 +69,7 @@ commits rebased + pushed to `origin/main` directly.
### H2 — moodEntrySchema.tags strict JSON-array validation
- **Commit:** `7f1a4de fix(validation): moodEntry.tags must parse as
JSON string-array (v1.4.15 H2)`.
JSON string-array (v1.4.15 H2)`.
- **Files:** `src/lib/validations/backup.ts:72-99`,
`src/lib/validations/__tests__/backup.test.ts` (new, 7 tests).
- Refined `tags` to `null | "" | <JSON-string-array>`; everything
@@ -84,7 +84,7 @@ commits rebased + pushed to `origin/main` directly.
### H4 code-review — tour-launcher sessionStorage scoping
- **Commit:** `2afe3c4 fix(onboarding): scope tour-launcher
sessionStorage keys by user id (v1.4.15 H4)`.
sessionStorage keys by user id (v1.4.15 H4)`.
- **Files:** `src/components/onboarding/tour-launcher.tsx:46-100`,
`src/app/onboarding/page.tsx:127-150`,
`src/components/onboarding/__tests__/tour-launcher-keys.test.ts`
@@ -98,7 +98,7 @@ commits rebased + pushed to `origin/main` directly.
### H4 design — 44 px tap targets on tour / backups / notifications
- **Commit:** `b863e2c fix(ui): tap-targets reach 44px on tour,
notification, backups buttons (v1.4.15 H4)`.
notification, backups buttons (v1.4.15 H4)`.
- **Files:** `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`.
@@ -118,7 +118,7 @@ commits rebased + pushed to `origin/main` directly.
### Bonus: i18n parity for B5a citation footnote
- **Commit:** `2a7ef72 fix(i18n): German translations for
insights.recommendation.{source,viewSource}`.
insights.recommendation.{source,viewSource}`.
- B5a added two English keys without German mirrors; pre-existing
failure on the locale-integrity guard test went green.
@@ -127,7 +127,7 @@ commits rebased + pushed to `origin/main` directly.
### MED 1 — insights/admin tab strip overflow
- **Commit:** `d7c2b2a fix(tabs): horizontal scroll on mobile
prevents overflow (insights + admin)`.
prevents overflow (insights + admin)`.
- `tabs.tsx` primitive: `overflow-x-auto` + `touch-pan-x` + `max-w-full`
on `tabsListVariants`. Affects every page using `<TabsList>`.
Vertical-tabs orientation kept its `overflow-x-visible` to avoid
@@ -143,7 +143,7 @@ commits rebased + pushed to `origin/main` directly.
### MED 4 — bottom-nav 5+More
- **Commit:** `072eee6 fix(nav): bottom-nav 5+More overflow protects
44px tap targets`.
44px tap targets`.
- `src/components/layout/bottom-nav.tsx` rewritten with PRIMARY
(Home, Measurements, Mood, Medications, Insights) + OVERFLOW
(Targets, Achievements) inside a Radix `<Sheet>`. Active state
@@ -153,7 +153,7 @@ commits rebased + pushed to `origin/main` directly.
### MED 5 — system-status loading-failed Retry button
- **Commit:** `65b4bf9 fix(admin): system-status load-failed pairs
alert with Retry button`.
alert with Retry button`.
- `system-status-section.tsx` adds an inline Retry button driven by
React Query's `refetch()`. Two new i18n keys + drop the "refresh
the page" tail from the existing message.
@@ -163,7 +163,7 @@ commits rebased + pushed to `origin/main` directly.
- **Status:** DEFERRED to v1.5 — documented in
`docs/audit/v1416-auto-deploy-fix.md`.
- **Commit:** `4be6465 docs(deploy): v1.4.16 auto-deploy follow-up
audit + DEFER to v1.5`.
audit + DEFER to v1.5`.
- Coolify v4-beta MCP API does not expose `auto_deploy_enabled` on
the application. Marc-side UI flip ("Configuration → Auto Deploy
→ OFF") is the realistic fix; doing it programmatically requires
@@ -175,15 +175,15 @@ commits rebased + pushed to `origin/main` directly.
## 5. docker-publish main-branch hang
- **Commit:** `cc0f343 fix(ci): docker-publish reliable on main-branch
(drop qemu-arm64)`.
(drop qemu-arm64)`.
- **Root cause:** the qemu-arm64 path (`docker/setup-qemu-action`
+ `tonistiigi/binfmt`) was SIGILL-crashing Next.js's static-page-
generation workers (`Next.js build worker exited with code: null
and signal: SIGILL` at 64-66/86 pages). V8's optimising tier emits
CPU instructions that qemu's user-mode emulation cannot reliably
translate under heavy load. Tag-builds appeared to "work" only
because they sometimes finished the static phase before the crash
window.
- `tonistiigi/binfmt`) was SIGILL-crashing Next.js's static-page-
generation workers (`Next.js build worker exited with code: null
and signal: SIGILL` at 64-66/86 pages). V8's optimising tier emits
CPU instructions that qemu's user-mode emulation cannot reliably
translate under heavy load. Tag-builds appeared to "work" only
because they sometimes finished the static phase before the crash
window.
- **Fix:** drop `linux/arm64` from the `platforms:` list. Marc's
prod is x86_64 only; the arm64 image was a courtesy build for OSS
users. v1.5 plan: re-add via a matrix on `ubuntu-24.04-arm`
+32 -32
View File
@@ -22,14 +22,14 @@ land in v1.4.16+ — see `docs/audit/v1416-ai-roadmap.md`.
## Commits (in order)
| Commit | Subject | Role |
| ----------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ |
| `27310e4` | refactor(ai): consolidate providers behind AIProvider interface | MockAIProvider + 7 contract tests (every provider satisfies CompletionResult shape) |
| `5510ed5` (sibling-merged, scope is mine) | docs(audit): v1.4.15 empty-states audit + i18n keys | swept in my schema.ts + generate-insight.ts + generate-insight.test.ts (parallel-agent race) |
| `d657f79` | feat(ai): enforce citation-from-data on every recommendation | 11-test invariant suite — schema rejects missing/empty metricSource; cross-check enforced |
| `4e85c38` | feat(ai): scope-hardened system prompt with refusal pattern | Versioned prompt at `src/lib/ai/prompts/insight-generator.ts` (`PROMPT_VERSION = "4.15.0"`) + 18 tests |
| `4bba951` | feat(ai): fallback-chain slug discovery with 1h positive cache | `CodexClient` walks fallback chain + cache; spec §7b extended; 20 tests |
| `fa11f10` | docs(audit): v1.4.16 AI hardening roadmap | Iteration plan Marc explicitly invited |
| Commit | Subject | Role |
| ----------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `27310e4` | refactor(ai): consolidate providers behind AIProvider interface | MockAIProvider + 7 contract tests (every provider satisfies CompletionResult shape) |
| `5510ed5` (sibling-merged, scope is mine) | docs(audit): v1.4.15 empty-states audit + i18n keys | swept in my schema.ts + generate-insight.ts + generate-insight.test.ts (parallel-agent race) |
| `d657f79` | feat(ai): enforce citation-from-data on every recommendation | 11-test invariant suite — schema rejects missing/empty metricSource; cross-check enforced |
| `4e85c38` | feat(ai): scope-hardened system prompt with refusal pattern | Versioned prompt at `src/lib/ai/prompts/insight-generator.ts` (`PROMPT_VERSION = "4.15.0"`) + 18 tests |
| `4bba951` | feat(ai): fallback-chain slug discovery with 1h positive cache | `CodexClient` walks fallback chain + cache; spec §7b extended; 20 tests |
| `fa11f10` | docs(audit): v1.4.16 AI hardening roadmap | Iteration plan Marc explicitly invited |
Cross-agent observation: same shared-cwd race documented across A2 /
A4 / B-mobile / B1-B6 / C2 / C3 / C4 / B5 of the v1.4.15 marathon
@@ -89,11 +89,11 @@ version. Versioning policy documented in the prompt file's header.
```ts
DEFAULT_SLUG_FALLBACK_CHAIN = [
"gpt-5.3-codex", // verified 2026-05-09 on Plus/Pro
"gpt-5-codex", // historical default — kept as second-chance retry
"gpt-5", // bare slug — currently rejected on ChatGPT-auth
"gpt-4o", // last-ditch capability fallback
]
"gpt-5.3-codex", // verified 2026-05-09 on Plus/Pro
"gpt-5-codex", // historical default — kept as second-chance retry
"gpt-5", // bare slug — currently rejected on ChatGPT-auth
"gpt-4o", // last-ditch capability fallback
];
```
Override via `CODEX_MODEL_FALLBACK_CHAIN` (comma-separated). When
@@ -104,15 +104,15 @@ survive behind it. Stable de-duplication.
Per `docs/codex-protocol-spec.md` §7b (extended in this phase):
| Status | Action |
| ----------------------------------------------- | ------------------------------------- |
| `400` + "not supported when using Codex with a ChatGPT account" | walk |
| `400` + `model_not_found` | walk |
| `400` + `does not exist` AND mentions a model | walk |
| `404` | walk |
| `401` (first time) | refresh and retry SAME slug; DON'T walk |
| `401` after refresh, `403`, `429`, `5xx` | propagate (don't walk) |
| `200` + SSE `response.failed.error.code === "invalid_prompt"` | propagate (request shape is wrong) |
| Status | Action |
| --------------------------------------------------------------- | --------------------------------------- |
| `400` + "not supported when using Codex with a ChatGPT account" | walk |
| `400` + `model_not_found` | walk |
| `400` + `does not exist` AND mentions a model | walk |
| `404` | walk |
| `401` (first time) | refresh and retry SAME slug; DON'T walk |
| `401` after refresh, `403`, `429`, `5xx` | propagate (don't walk) |
| `200` + SSE `response.failed.error.code === "invalid_prompt"` | propagate (request shape is wrong) |
Positive cache: process-local Map at `src/lib/ai/codex-slug-cache.ts`,
single slot, 1 h TTL. Cache hit makes the working slug come first on
@@ -152,16 +152,16 @@ the wrapper passes it through cleanly.
## Tests added
| File | Tests | Focus |
| ------------------------------------------ | ----- | ------------------------------------------------------------ |
| `provider-contract.test.ts` | 7 | Every provider returns CompletionResult shape |
| `mock-client.ts` | - | Test infrastructure, no tests of its own (covered above) |
| `schema.ts` (no test file) | - | Tested via the next two files |
| `generate-insight.test.ts` | 11 | Schema parse, retry-once with correction, 422 on second-fail |
| `citation-enforcement.test.ts` | 11 | Schema-level + cross-check + wrapper end-to-end |
| `insight-generator-prompt.test.ts` | 18 | Both locales, scope hardening, refusal payload, ESH/ESC etc. |
| `codex-slug-cache.test.ts` | 7 | TTL boundaries, invalidation, replacement |
| `codex-slug-fallback.test.ts` | 13 | Walk triggers, no-walk on 5xx/429/401, all-failed, cache |
| File | Tests | Focus |
| ---------------------------------- | ----- | ------------------------------------------------------------ |
| `provider-contract.test.ts` | 7 | Every provider returns CompletionResult shape |
| `mock-client.ts` | - | Test infrastructure, no tests of its own (covered above) |
| `schema.ts` (no test file) | - | Tested via the next two files |
| `generate-insight.test.ts` | 11 | Schema parse, retry-once with correction, 422 on second-fail |
| `citation-enforcement.test.ts` | 11 | Schema-level + cross-check + wrapper end-to-end |
| `insight-generator-prompt.test.ts` | 18 | Both locales, scope hardening, refusal payload, ESH/ESC etc. |
| `codex-slug-cache.test.ts` | 7 | TTL boundaries, invalidation, replacement |
| `codex-slug-fallback.test.ts` | 13 | Walk triggers, no-walk on 5xx/429/401, all-failed, cache |
Total: +67 unit tests across 6 new files. 965 → 1048 unit pass
(+83 net from baseline; some sibling-agent tests merged in too).
+3 -3
View File
@@ -4,10 +4,10 @@
**Wall clock:** 2026-05-09T21:10 → 2026-05-09T21:25 Berlin (~15 min)
**Commits on `origin/main`:**
| SHA | Message (real diff scope) |
| --------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| SHA | Message (real diff scope) |
| --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `34c967c` | `feat(achievements): …` — sibling-agent commit message; **my** docker-publish.yml + .env.example + docs/audit/v1415-auto-deploy.md hunks rode along (5th run of the shared-cwd race documented in A2/A4/B1/B-mobile/B2/B3). |
| `ad350fe` | `feat(deploy): admin notification + audit log on deploy success/failure` — clean, my files only (`git commit -o` pathspec). |
| `ad350fe` | `feat(deploy): admin notification + audit log on deploy success/failure` — clean, my files only (`git commit -o` pathspec). |
Linked deep-dive: [`docs/audit/v1415-auto-deploy.md`](../docs/audit/v1415-auto-deploy.md).
+16 -14
View File
@@ -3,10 +3,11 @@
**Status**: done
**Wall clock**: 2026-05-09T20:30 → 2026-05-09T20:55 Berlin (~25 min)
**Commits on `origin/main`**:
- `41945b2` fix(test): de-flake e2e a11y suite by forcing dark colorScheme
- `249c42b` fix(ci): docker-publish reliability — separate cache scope per ref + 30min timeout
- `ffa4aac` feat(ci): post-publish verify workflow
- `4a5be22` docs(audit): v1.4.15 CI/e2e reliability report
- `41945b2` fix(test): de-flake e2e a11y suite by forcing dark colorScheme
- `249c42b` fix(ci): docker-publish reliability — separate cache scope per ref + 30min timeout
- `ffa4aac` feat(ci): post-publish verify workflow
- `4a5be22` docs(audit): v1.4.15 CI/e2e reliability report
Linked deep-dive: [`docs/audit/v1415-ci-reliability.md`](../docs/audit/v1415-ci-reliability.md)
@@ -47,12 +48,12 @@ manifest exporter and one deadlocked indefinitely (no timeout).
## Fixes shipped
| Commit | Surface | Effect |
| --------- | ---------------------------------- | ---------------------------------------------- |
| `41945b2` | `playwright.config.ts` | e2e now scans the actual user-facing dark UI |
| `249c42b` | `.github/workflows/docker-publish.yml` | per-ref cache scope, 30-min timeout |
| Commit | Surface | Effect |
| --------- | ------------------------------------------- | --------------------------------------------- |
| `41945b2` | `playwright.config.ts` | e2e now scans the actual user-facing dark UI |
| `249c42b` | `.github/workflows/docker-publish.yml` | per-ref cache scope, 30-min timeout |
| `ffa4aac` | `.github/workflows/post-publish-verify.yml` | informational reachability gate after publish |
| `4a5be22` | `docs/audit/v1415-ci-reliability.md` | full audit + recommendations |
| `4a5be22` | `docs/audit/v1415-ci-reliability.md` | full audit + recommendations |
The fix-set explicitly excludes scope I was told not to touch:
`src/components/charts/**`, `admin/backups`, `withings/**`,
@@ -90,12 +91,12 @@ docker-publish + post-publish-verify fixes live in `.github/workflows`.
## Pass-rate impact
| Stage | 30-day Pre-C3 | Expected Post-C3 |
| ------------------------------- | ------------- | ---------------- |
| Integration tests | 100 % (47/47) | 100 % |
| Stage | 30-day Pre-C3 | Expected Post-C3 |
| ------------------------------- | ------------- | ------------------------- |
| Integration tests | 100 % (47/47) | 100 % |
| Security & Quality | 93 % (43/46) | unchanged (A4 fix needed) |
| Docker publish (completed only) | 91 % (30/33) | ≥ 95 % |
| e2e | 0 % (0/47) | ≥ 90 % |
| Docker publish (completed only) | 91 % (30/33) | ≥ 95 % |
| e2e | 0 % (0/47) | ≥ 90 % |
The 0 → ≥90 % e2e jump is the headline result. Marc's "tests must
really run through" gate is unblocked.
@@ -103,6 +104,7 @@ really run through" gate is unblocked.
## Race-condition observations (operational note)
7 parallel agents pushing to `main` produced:
- 26 of 60 docker-publish runs cancelled by `concurrency.cancel-in-progress`
(by-design; no action needed)
- Two of my own commits picked up B-agent staged files mid-`git add`
+9 -9
View File
@@ -12,15 +12,15 @@ EN+DE translations as part of their own commits. By the time C4 ran
sequentially after Batch 2, both locale files were already in perfect
parity:
| Check | Count |
| ---------------------------------- | ----: |
| Total keys (EN) | 1817 |
| Total keys (DE) | 1817 |
| Keys only in EN | 0 |
| Keys only in DE | 0 |
| Empty values | 0 |
| TODO/FIXME placeholders | 0 |
| Used in `src/` but missing in EN | 0 (after JSDoc/dynamic-prefix/test-fixture allow-list) |
| Check | Count |
| -------------------------------- | -----------------------------------------------------: |
| Total keys (EN) | 1817 |
| Total keys (DE) | 1817 |
| Keys only in EN | 0 |
| Keys only in DE | 0 |
| Empty values | 0 |
| TODO/FIXME placeholders | 0 |
| Used in `src/` but missing in EN | 0 (after JSDoc/dynamic-prefix/test-fixture allow-list) |
All v1.4.15-introduced namespaces (`admin.section.backups.*`,
`achievements.*`, `onboarding.tour.*`, `admin.overview.*`,
+7 -7
View File
@@ -32,24 +32,24 @@ API changes.
- `5510ed5` `docs(audit): v1.4.15 empty-states audit + i18n keys` —
audit document at `docs/audit/v1415-empty-states.md`; 18 new EN+DE
keys under `measurements/mood/medications/admin.section.users/
admin.section.backups/admin.{loginEmpty,tokensEmpty}/admin.feedback/
insights/dashboard`. Sibling-agent C1 AI files swept in by the
admin.section.backups/admin.{loginEmpty,tokensEmpty}/admin.feedback/
insights/dashboard`. Sibling-agent C1 AI files swept in by the
shared-cwd race (per STATE.md pattern); files correct on `main`,
message scope misleading.
- `0c20119` `feat(admin): empty states for users, backups,
login-overview, api-tokens, feedback` — 6 admin sub-routes upgraded.
login-overview, api-tokens, feedback` — 6 admin sub-routes upgraded.
Adds `user-management-empty.test.tsx` + `backups-section-empty.test.tsx`
guarding primitive mount, localized strings, and CTA-presence
semantics.
- `9a74f8e` `feat(lists): empty states for measurements, mood,
medications, achievements` — only carried the new
medications, achievements` — only carried the new
`measurement-list-empty.test.tsx` due to a sibling-cwd staging race;
message-scope wider than diff.
- `1d65f3b` `feat(lists): empty states for measurements, mood,
medications, achievements (impl)` — follow-up that lands the actual
medications, achievements (impl)` — follow-up that lands the actual
EmptyState wiring described in the previous commit's message.
- `65faf1d` `feat(insights,dashboard): empty states for first-run
views` — `/insights` top-level + BMI height-not-set + dashboard
views` — `/insights` top-level + BMI height-not-set + dashboard
fully-empty paths.
## Tests
@@ -80,7 +80,7 @@ C2) reproduced twice during this phase:
2. Commit `9a74f8e` was supposed to carry the four feature-list impl
files plus the new test, but only the new test got staged — six
modified files reverted from the index between `git add` and `git
commit`. The follow-up commit `1d65f3b` lands the impl with an
commit`. The follow-up commit `1d65f3b` lands the impl with an
explicit message tying the two together.
Both patterns match STATE.md's documented "v1.4.16 should adopt
+4 -3
View File
@@ -28,9 +28,10 @@ forwards its regenerate button to the same handler. The 7 per-section
surface is additive, not a replacement.
E2E proof: `e2e/insights-advisor-card.spec.ts` mocks the analytics
+ comprehensive + per-status + advisor endpoints and asserts the
hero, the rec text, the ConfidenceMeter slot, and the summary prose
all render on the live `/insights` route.
- comprehensive + per-status + advisor endpoints and asserts the
hero, the rec text, the ConfidenceMeter slot, and the summary prose
all render on the live `/insights` route.
## C2 — Mount `<InsightsCardPreview>` on `/` — **DONE**
+521 -143
View File
@@ -1,190 +1,568 @@
# Phase D — Code-review findings (v1.4.16)
# v1.4.18 Phase-D Code Review Findings
Reviewer: senior code-review (parallel with security / design / senior-dev / simplify / Product Lead).
Scope: diff `v1.4.15...HEAD` — Wave A (A1–A8), Wave B (B1a/b, B2, B3, B4, B5a–e, B6, B7, B8), Wave C (CI + deferred HIGH).
Method: targeted reads of hot paths — AI provider chain + confidence + schema (B5a–d), feedback loop (B5e), host-metrics + app-logs (B3/B4), exports (B7), comparison overlay (B8), BD-Zielbereich predicate (A2). No source edits, no commits.
Reviewer: code-reviewer agent
Run: 2026-05-10 (parallel with 5 other reviewers)
Scope: v1.4.17 → origin/main (22 commits)
DO NOT commit — findings only.
---
Diff scope (from `git log --oneline v1.4.17..origin/main`):
## CRITICAL — ship-blocker
- A1 BD-Zielbereich tile sub-values (commits 23363ca, 15c852f)
- A2 painted-scrollbar fix (commits 3e16074, 874878f)
- A3 chart visual revert + per-chart toggles (commits 008a8fb, 06f18df, b388fc0, 75f340e, d70a9b1, 3d58557, d6e87c3)
- B1 achievements expansion + hidden Easter-eggs (commits e73813a, 19b0844, c41f9a4, 23514b4, b88f2de, 6912d4e, 75c74f1, 178c6dc)
(none)
## CRITICAL
---
### C1 — Hidden achievement triggers leak via the API response (and the client bundle)
- **Severity**: CRITICAL
- **File**: `src/app/api/gamification/achievements/route.ts:611-655` and
`src/lib/gamification/achievements.ts:706-739` (evaluator) +
`src/lib/i18n/context.tsx:15-22` (bundle import)
- **Issue**: The reviewer brief lists this exact failure mode as ship-
blocker: _"the 'Hidden' placeholder must not reveal predicate
conditions to a snooping user"_. The achievements page UI does the
right thing — `<AchievementCard>` for hidden+locked entries renders
an opaque card with only a generic "Hidden achievement" label and
the help-circle icon. **However, the wire response from
`GET /api/gamification/achievements` ships the full achievement
definition for every entry including hidden+locked**:
```json
{
"id": "hidden-night-owl",
"metric": "nightOwlCount",
"category": "hidden",
"titleKey": "achievements.badges.hiddenNightOwl.title",
"descriptionKey": "achievements.badges.hiddenNightOwl.description",
"icon": "Moon",
"format": "count",
"target": 1,
"current": 0,
"points": 25,
"unlocked": false,
"progressPercent": 0,
"completedAt": null,
"isHidden": true
}
```
Three independent leak vectors a "snooping user" can use without any
privileged access:
1. The metric name itself (`nightOwlCount`, `earlyBirdCount`,
`leapDayCount`, `doctorPdfCount`, `localeFlipCount`) leaks the
trigger semantically.
2. The `titleKey` / `descriptionKey` strings reference the i18n
bucket whose translated values describe the trigger verbatim
(`"Logged an entry between 02:00 and 04:00 in the morning."`).
3. `messages/en.json` and `messages/de.json` are statically
`import`-ed in `src/lib/i18n/context.tsx` (lines 15-16) and
therefore bundled into the client JS — a `Cmd-F` for
`"hiddenNightOwl"` in the chunk reveals every trigger and
reward.
Items 1+2 are the ship-blocker the brief explicitly asks for. Item 3
is a related but separable concern that needs a different fix.
Critically, the `<AchievementCard>` opaque-placeholder branch makes
this leakage _less obvious to casual users_ but does nothing to
prevent a determined snooper — Marc, who specifically asked for
hidden Easter-eggs to be a surprise, would discover them in 30
seconds with the Network tab open.
Existing test coverage (`src/app/achievements/__tests__/page.test.tsx`
lines 254-270) only asserts that the rendered HTML doesn't contain
the strings — _that test passes_ even though the strings are still
in the JSON response and the bundle. The test is not actually
guarding what the brief asked us to guard.
- **Recommendation**: Strip identifying fields from hidden+locked
achievement entries server-side before returning them. The wire
shape for a hidden+locked entry should look like:
```json
{
"id": "hidden-night-owl",
"category": "hidden",
"isHidden": true,
"unlocked": false,
"completedAt": null
}
```
i.e. drop `metric`, `titleKey`, `descriptionKey`, `icon`, `target`,
`current`, `progressPercent`, `points`. A stable `id` is fine to
expose because the i18n key is namespaced under `hidden*` (the user
can still infer that hidden achievements exist; that's the whole
point) but the trigger details must not be transmitted.
Concretely, in `src/app/api/gamification/achievements/route.ts`
around line 611 (where `applyDiscoveryFilter` is called), add a
second pass that scrubs hidden+locked entries before serialising:
```ts
const sanitized = visibleAchievements.map((a) => {
if (a.isHidden && !a.unlocked) {
return {
id: a.id,
category: "hidden" as const,
isHidden: true,
unlocked: false,
completedAt: null,
// Synthetic placeholders for type-stability so the client
// doesn't need an `?.` everywhere. The real strings only
// ship once the achievement unlocks.
titleKey: "achievements.hiddenCard.title",
descriptionKey: "achievements.hiddenCard.description",
icon: "HelpCircle",
format: "count" as const,
target: 0,
current: 0,
points: 0,
metric: "totalTakenIntakes" as const, // sentinel
progressPercent: 0,
};
}
return a;
});
```
And add an explicit test at the API/route level that asserts the
response body does NOT contain `nightOwlCount`, `earlyBirdCount`,
`leapDayCount`, `doctorPdfCount`, `localeFlipCount` while the
achievement is locked, and DOES contain those strings once
unlocked.
Bundle leak (item 3) is a separate fix: gate hidden i18n strings
behind a server-side translator and return the resolved title /
description in the unlock toast payload only when the unlock
actually occurs (or move them to a server-only translation file
loaded by `getServerTranslator`). Lower-effort interim is a comment
in `messages/*.json` explaining the trade-off and accepting that
any client-bundle leak below the API leak is "acceptable risk for
v1.4.18" — but that's a Marc-call, not a reviewer-call.
- **Ship-blocker?**: YES. The brief explicitly calls this out as
CRITICAL ("hidden achievement leakage in DOM/network responses").
The DOM leak is fixed (the card renders correctly); the **network
response leak is not**. This must be fixed before v1.4.18 ships.
## HIGH
### H1. Bucket-key collision in `feedback-aggregator` allows cross-bucket pollution
- **File:** `src/lib/jobs/feedback-aggregator.ts:70-75`
- **Issue:** `[severity, metricSourceType, providerType, promptVersion].join("")` uses the empty separator. `severity` is bounded enum but `metricSourceType` is free-form (z.string().min(1).max(80)) and `providerType`/`promptVersion` are wire-stamped. Collision example: `severity="info"` + `metricSourceType="important"` collides with `severity="important"` + `metricSourceType="info"` — both produce key `"infoimportant…"`. The first to arrive wins ownership; subsequent rows accumulate counts onto a row whose displayed `(severity, metricSourceType)` belongs to the wrong tuple, silently poisoning the admin AI quality slice.
- **Recommendation:** Join with an unambiguous separator that cannot appear in any of the four fields, e.g. `\x1f` (US) or a JSON tuple: `JSON.stringify([severity, metricSourceType, providerType, promptVersion])`.
- **Ship-blocker for v1.4.16?** Yes for the admin AI-quality feature semantics; can be deferred if the dashboard is treated as best-effort observability.
### H1 — Read-modify-write race when two charts toggle overlays simultaneously
### H2. `FallbackChainCard` discards the persisted `enabled` flag — disabled providers vanish from the UI
- **Files:** `src/app/api/insights/provider-chain/route.ts:49-56`, `src/components/settings/ai-section.tsx:1272-1283`
- **Issue:** The GET response only returns `{ providerType, available }`. The UI seeds `enabled: true` for every entry (`chain.map((c) => ({ providerType: c.providerType, enabled: true }))`). But `resolveProviderChain()` upstream (`src/lib/ai/provider.ts:306`) **filters out disabled entries before returning** (`parseProviderChain(rawChain).filter((e) => e.enabled)`). Net effect: a user who disables an entry sees it disappear from the list; on reload it cannot be re-enabled because it's not present, and the toggle position is lost. The UI advertises the toggle as a "disable in fallback chain" affordance but is functionally a "remove from chain" button.
- **Recommendation:** GET should return the raw persisted `parseProviderChain(rawChain)` (not the resolveProviderChain filtered list) plus `available` per entry. The UI then renders both enabled and disabled rows so the user can re-enable. Or: remove the toggle entirely and only support add/remove.
- **Ship-blocker for v1.4.16?** Yes — it's a regression vs. the documented B2 brief ("toggle drives enable/disable"). Either fix the round-trip or remove the Switch from the row.
- **Severity**: HIGH (correctness — concurrent writes lose data)
- **File**: `src/app/api/dashboard/chart-overlay-prefs/route.ts:55-79`
and `src/app/api/dashboard/widgets/route.ts:96-115`
- **Issue**: Both routes do an unwrapped read-modify-write against
`User.dashboardWidgetsJson`:
### H3. Strict B5b/B5c/B5d AI features are not wired into the production route
- **Files:** `src/app/api/insights/generate/route.ts:280-393`, `src/lib/ai/generate-insight.ts`, `src/lib/ai/provider-runner.ts:229-288`
- **Issue:** The production `/api/insights/generate` route uses `runRawCompletionWithFallback()` (raw `provider.generateCompletion`, no schema enforcement, no rationale check, no confidence override). The strict `runWithFallback()` + `generateInsight()` (which enforces `aiRecommendationRationaleSchema`, runs the corrective retry, OVERRIDES `rec.confidence` deterministically, annotates `ai_confidence_override_pairs`) is consumed only in tests. Result: in production, the model's self-reported confidence is what users see — defeating B5d's "calibrated probabilities are not a small-LLM strength" mandate; rationale + citation enforcement is observational only via `insightResultSchema.safeParse()` falling back to raw parsed JSON on validation failure (line 386 `insights = validated.success ? validated.data : parsed`).
- **Recommendation:** Either (a) migrate `/api/insights/generate` to `runWithFallback()` + new `aiInsightResponseSchema`, replacing the legacy passthrough — STATE.md notes this as a deferred "v1.4.17 migration"; or (b) add a **runtime feature-flag** gate so the strict path can be enabled in prod without a code release. Today's `applyConfidenceOverride()` and rationale-required schema are dead code in production.
- **Ship-blocker for v1.4.16?** Maybe — depends on whether v1.4.16's release notes claim "deterministic confidence" or "rationale-required" as user-facing features. STATE.md B5c/B5d both say "complete on origin/main" but the production path is unchanged. Users will see model-claimed confidence values, not server-computed ones. Recommend explicit Marc-call: ship with feature dark + clearer release-notes wording, or migrate the route.
```ts
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { dashboardWidgetsJson: true },
});
const current = resolveDashboardLayout(row?.dashboardWidgetsJson);
const next: DashboardLayout = {
...current,
chartOverlayPrefs: {
...(current.chartOverlayPrefs ?? {}),
[parsed.data.chartKey]: parsed.data.prefs,
},
};
await prisma.user.update({
where: { id: user.id },
data: { dashboardWidgetsJson: normalized },
});
```
### H4. `findRecommendationsMissingRationale` is a no-op against parsed AIInsightResponse
- **File:** `src/lib/ai/schema.ts:283-297`, `src/lib/ai/generate-insight.ts:158-175`
- **Issue:** The strict schema requires `rationale` (`aiRecommendationRationaleSchema` is required on every rec — line 157). If the parser succeeds, every rec will have a rationale, so the helper always returns `[]`. The Wide-Event annotation `ai_rationale_missing_recommendation_ids` is therefore always empty in the strict path — and the strict path is the only place the helper fires. The "observability tripwire for the migration window" comment is misleading.
- **Recommendation:** Either feed it a separate looser-typed pre-parse blob (e.g. the JSON before `aiInsightResponseSchema.safeParse()` runs), or move the call to the `isLegacyInsightPayload()` cache-hit path where it would actually surface drift. Document or remove the tripwire claim.
- **Ship-blocker for v1.4.16?** No — observability dead-code is not a functional bug. Worth fixing for clarity.
If a user opens the dashboard, flips the BP chart's "Target range"
toggle, and 50ms later flips the weight chart's "Trend" toggle, the
two `PUT` calls can interleave: BP-PUT reads the current layout,
weight-PUT reads the _same_ current layout (concurrent), BP-PUT
writes the merged BP prefs, weight-PUT then writes its merged
weight prefs back **on top of the layout it read** — which still
has the old BP prefs — silently dropping the BP toggle. The user
sees the BP toggle revert when the page next refreshes.
### H5. `recommendation-feedback` has no rate-limit
- **File:** `src/app/api/insights/feedback/route.ts:30-101`
- **Issue:** No `checkRateLimit()` call. Each thumbs-click writes one `RecommendationFeedback` row + one `audit_logs` row. The unique index `(userId, recommendationId, recommendationText)` prevents exact-duplicate inserts, but a malicious client can rotate `recommendationId` or `recommendationText` per request and flood the DB. Idempotency-Key cache only catches identical retries with the same key.
- **Recommendation:** Add `checkRateLimit('feedback:${user.id}', 60, 60 * 60 * 1000)` (60/h is generous — a comprehensive insight rarely has >10 recs, so ≥6 regenerations/h before throttle hits).
- **Ship-blocker for v1.4.16?** No — single-user scoped, inside-auth. Fix in v1.4.17.
The optimistic update in `useChartOverlayPrefs` (`onMutate`)
partially masks this for the originating client because the local
cache stays correct until the route returns; once the route
returns successful and `onSettled` invalidates the query, the next
fetch reveals the dropped toggle.
### H6. `recommendation-feedback` invalidates the entire `["insights"]` cache on every thumbs click
- **File:** `src/components/insights/recommendation-feedback.tsx:192`
- **Issue:** `queryClient.invalidateQueries({ queryKey: ["insights"] })` matches ALL `["insights", …]`-prefixed keys (8 queries: comprehensive + 7 per-status). On thumbs success this kicks off a synchronous refetch of all 8 — none of which are affected by the feedback row itself. Refetching the comprehensive blob requires a 1500-token LLM call (rate-limited 10/h) — the only thing that prevents this is the 24h server-side cache-hit path. Effectively the user gets no surprise refetch in practice but the network and cache thrash is wasted work, and on a stale cache it's a real LLM round-trip.
- **Recommendation:** Drop the invalidation entirely; feedback doesn't affect the displayed insight content. If a future mode (B5e ratchet in v1.4.17) makes feedback feed back into next-gen prompts, scope the invalidation to the per-status / comprehensive query that would consume the new prompt.
- **Ship-blocker for v1.4.16?** No — minor performance smell.
The same race exists for `/api/dashboard/widgets` PUT (already had
this defect in v1.4.16 + B8 comparison baseline) but the new chart-
overlay-prefs route makes it worse because the surface for
per-chart toggles is exactly the kind of thing users will
rapid-fire (5 charts × 3 toggles = 15 settings to flip in a
session).
### H7. Comparison overlay re-uses already-fetched `data` as the "prior period" — there is NO prior-period data
- **Files:** `src/components/charts/health-chart.tsx:524-563`, `src/lib/charts/comparison-shift.ts`
- **Issue:** `chartDataWithCompare` shifts the SAME `data` array forward by 30/365 days. The dashboard fetcher upstream pulls `data` as the visible window only (e.g. last 30 days). Shifting today's last-30-days forward by 30 days produces "today's data overlaid 30 days into the future" — not "last month's data overlaid onto today". For the comparison to be meaningful the chart needs a SEPARATE fetch of the prior 30/365-day window. STATE.md B8 says "shift the SAME daily aggregates and shift them forward by 30 days (lastMonth) or 365 days (lastYear) so the prior period lines up with the current period on the visible x-axis" — only correct if `data` already contains BOTH periods. Verify upstream `data` covers the full extended range.
- **Recommendation:** Audit the call site — does the dashboard fetcher pull `now - rangeDays` only, or `now - rangeDays - shift`? If the former, the overlay shows `current_period` at `current_period + 30d` (a phantom future overlay). If the latter, OK. Add a unit test that pins `data` containing both periods and asserts the visible overlay maps to the prior-period values, not the current period's values.
- **Ship-blocker for v1.4.16?** Possibly — needs fast verification. If `data` is single-period, the overlay is structurally meaningless.
- **Recommendation**: Wrap the read-modify-write in a transaction
with `SERIALIZABLE` isolation OR rewrite the merge to use Postgres'
JSON path operators so the SQL itself is the merge. Cheapest fix
is the transaction wrapper:
### H8. `seedKey` includes potentially-undefined `chainData?.activeProvider` so race between query resolution and URL change re-seeds twice
- **File:** `src/components/settings/ai-section.tsx:228-241`
- **Issue:** `seedKey = `${queryProvider ?? ""}|${chainData?.activeProvider ?? ""}`` mutates between renders — first render `chainData` is undefined → seedKey ends with `"|"`; on chainData arrival → seedKey ends with `"|codex"`. The `seededFor !== seedKey` branch fires twice, second time setting `selectedProvider` from chainData. If the user has already clicked a different provider in the dropdown between the two renders, their click is silently overridden by the chainData arrival.
- **Recommendation:** Track the user-interaction state separately (`userPicked: boolean`) and gate re-seeding on `!userPicked`. Or only seed once on the first non-undefined `chainData`.
- **Ship-blocker for v1.4.16?** No — narrow race window, recoverable by re-clicking. Fix in v1.4.17.
```ts
await prisma.$transaction(
async (tx) => {
const row = await tx.user.findUnique({
where: { id: user.id },
select: { dashboardWidgetsJson: true },
});
const current = resolveDashboardLayout(row?.dashboardWidgetsJson);
const next = {
...current,
chartOverlayPrefs: {
...(current.chartOverlayPrefs ?? {}),
[parsed.data.chartKey]: parsed.data.prefs,
},
};
await tx.user.update({
where: { id: user.id },
data: {
dashboardWidgetsJson: serializeDashboardLayout(
next,
) as Prisma.InputJsonValue,
},
});
},
{ isolationLevel: "Serializable" },
);
```
---
Add a vitest race-condition test against the existing testcontainer
setup (mirror `tests/integration/rate-limit-race.test.ts`). The
existing v1.4.18 integration test
(`tests/integration/chart-overlay-prefs.test.ts`) only exercises
serial writes and would not catch this defect.
## MEDIUM
- **Ship-blocker?**: NO — single-user, single-tab usage will not
trigger this (the optimistic update masks it). But a user with the
dashboard open in two tabs (a common pattern with PWAs) WILL hit
it. Fix in v1.4.18 if Marc wants; safe to defer to v1.4.19 with a
backlog entry.
### M1. `/api/admin/audit-log/actions` does an unbounded `groupBy(action)`
- **File:** `src/app/api/admin/audit-log/actions/route.ts:22-25`
- **Issue:** No index on `audit_logs.action`. With 1M+ rows the `groupBy` becomes an aggregate scan. Result is also un-cached.
- **Recommendation:** Add an index on `audit_logs.action`, OR cache the result for 5 min server-side, OR LIMIT the distinct list to the 100 most recent actions.
### H2 — `useChartOverlayPrefs` subscribes to "bp" prefs even when no chartKey is supplied
### M2. `RecommendationCard` `metricTypeToChartTypes()` falls through to verbatim type — silent passthrough
- **File:** `src/components/insights/recommendation-card.tsx:114-135`
- **Issue:** Unknown `metricType` returns `[metricType]` as-is. `HealthChart` then receives an unrecognised type and renders empty. Comment says "render empty rather than nothing" but the user sees nothing useful — a model that emits `metricSource.type = "BloodPressure"` (capitalised) gets dropped because the `lower === "bloodpressure"` branch hits a different casing for legacy-typed payloads. Worse: the model can emit any string.
- **Recommendation:** Allowlist explicitly; unknown types skip the mini-chart entirely (return `[]`). Add a Wide-Event annotation `ai_rec_unknown_metric_type` for observability.
- **Severity**: HIGH (correctness — wrong overlay state when chartKey is omitted)
- **File**: `src/components/charts/health-chart.tsx:321-324`
- **Issue**:
### M3. `appendLogEvent` runs unconditionally even for high-volume background jobs
- **Files:** `src/lib/logging/transports.ts:93-104`, `src/lib/logging/in-memory-buffer.ts`
- **Issue:** Every emitted event lands in the 500-entry ring buffer. For a worker process running pg-boss every minute this means the buffer is dominated by background events, leaving no space for HTTP request events that admins are more likely to debug. Plus the buffer is per-process so the web buffer never sees background events anyway — they ship to a buffer no admin reads.
- **Recommendation:** Skip background events from the buffer when `event.kind === "background"`, or partition by kind. The Loki sink still receives them.
```ts
const overlayPrefs = useChartOverlayPrefs(chartKey ?? "bp");
const showMA = chartKey ? overlayPrefs.prefs.showTrendIndicator : false;
const showTrend = chartKey ? overlayPrefs.prefs.showTrendArrow : false;
const showBands = chartKey ? overlayPrefs.prefs.showTargetRange : false;
```
### M4. `redactEventForEgress` does not redact `meta` or `action.details`
- **File:** `src/app/api/admin/app-logs/route.ts:72-96`
- **Issue:** Only `error.message`, `error.stack`, `warnings`, `http.user_agent`, `external_calls[].error` are redacted. `meta` and `action.details` carry annotated key=value pairs from `annotate()` calls — if any handler accidentally annotates a token (e.g. an Idempotency-Key replay logging the `Authorization` header), it leaks to the admin UI in raw form.
- **Recommendation:** Run `redactSecrets` recursively over `meta` and `action.details` string values too. Or whitelist a closed set of meta keys the egress allows.
When `chartKey` is undefined (mini mode / ad-hoc render), the hook
still subscribes to the dashboard-layout query and reads the
user's `bp` chart prefs — which are then ignored by the ternaries.
This is mostly wasteful (an extra fetch + reactive subscription),
but it has two real consequences:
1. A render thrash when the user updates BP chart prefs from the
dashboard: every mini chart re-renders even though their state
doesn't change.
2. If a user has no `bp` prefs but has `weight` prefs, the
defaulted-empty `bp` prefs leak through the optimistic-update
cache as "every flag false" — usually equivalent to the
correct default but conceptually wrong.
### M5. `chainBodySchema.providerType` cast `as unknown as [string, ...string[]]`
- **File:** `src/app/api/insights/provider-chain/route.ts:60-62`
- **Issue:** Double-cast hides the type. zod accepts `readonly [string, ...string[]]` from a tuple; `PROVIDER_CHAIN_TYPES` is declared with `as const` so it should already match. The double-cast risks a future PROVIDER_CHAIN_TYPES being non-tuple typed without compile error.
- **Recommendation:** `z.enum([...PROVIDER_CHAIN_TYPES] as readonly [string, ...string[]])` or use zod's `z.enum(PROVIDER_CHAIN_TYPES)` directly — the `as const` should already produce the tuple.
- **Recommendation**: Either pass `chartKey` to the hook only when
defined and short-circuit inside the hook when undefined, or move
the hook call inside a wrapper that early-exits before the hook is
called (the latter is a hooks-rule violation though, so prefer the
former):
### M6. `ContractCard` minor — "draft" pill doesn't use `litBarsFor` clamp comment correctly
- **File:** `src/components/insights/confidence-meter.tsx:55-64`
- **Issue:** Comment says "0..20 is 1, 21..40 is 2"; `Math.ceil(0/20) = 0` → clamped to 1; `Math.ceil(20/20) = 1`; but value < 25 routes to `draft` band BEFORE this is consulted. The clamp `if (lit < 1) return 1` defends against `value=0` for non-draft band, which is unreachable since `bandFor(0) === "draft"`. Dead code path; comment is correct in spirit but misleading.
- **Recommendation:** Drop the `lit < 1` clamp or document it as "defensive for refactoring."
```ts
// In use-chart-overlay-prefs.ts:
export function useChartOverlayPrefs(
chartKey: ChartOverlayKey | undefined,
): { prefs: ChartOverlayPrefs; setPrefs: ...; isSaving: boolean } {
const queryClient = useQueryClient();
const { data: layout } = useQuery({
queryKey: ["dashboard-layout"],
enabled: chartKey !== undefined, // skip when no key
...
});
const prefs = useMemo<ChartOverlayPrefs>(() => {
if (!chartKey) return DEFAULT_CHART_OVERLAY_PREFS;
return layout?.chartOverlayPrefs?.[chartKey] ?? DEFAULT_CHART_OVERLAY_PREFS;
}, [layout, chartKey]);
...
}
```
### M7. `host-metrics` route returns `BigInt → Number` without overflow guard
- **File:** `src/app/api/admin/host-metrics/route.ts:90`
- **Issue:** `Number(row.memTotalBytes)` and `Number(readDelta)` — if memTotalBytes ever exceeds 2^53 (~9 PB) precision is lost. Disk byte counters are cumulative; on a long-running machine they could overflow given enough time.
- **Recommendation:** Acceptable for v1.4.16 (typical hosts <16 GiB RAM, disks <10 PiB throughput). Document in the route comment.
- **Ship-blocker?**: NO — the user-visible behaviour is the clean-line
default (which is correct). Wasted bandwidth + re-render only.
### M8. `feedback-aggregator` `await prisma.appSettings.upsert` swallows errors silently
- **File:** `src/lib/jobs/reminder-worker.ts:951-972`
- **Issue:** Worker catches and warns but doesn't requeue. If the upsert fails (DB connection blip, lock contention) the day's summary is lost — admin AI-quality dashboard shows yesterday's data.
- **Recommendation:** Let pg-boss retry the job on throw (default behaviour). If retry storm is the concern, add a `maxRetries: 3, backoff: exponential` to the queue config. Today's "swallow + log" is too aggressive.
### H3 — Hidden bug-buddy achievement shares `bugReportCount` with public bugreport-1 — order-of-evaluation matters
### M9. `host-metric-sampler.getHostMetricRetentionDays` redundant double-check
- **File:** `src/lib/jobs/host-metric-sampler.ts:120-131`
- **Issue:** Returns DEFAULT for `parsed <= 0`, then again for `parsed < 1`. The second check is unreachable for valid integers.
- **Recommendation:** Drop the second check.
- **Severity**: HIGH (correctness around discovery)
- **File**: `src/lib/gamification/achievements.ts:354-375` (public
`bugreport-1`) and lines 638-647 (hidden `hidden-bug-buddy`)
- **Issue**: Both achievements use `metric: "bugReportCount"`. The
public one fires at target 1; the hidden one fires at target 5.
When the API response is serialised, both entries have
`metric: "bugReportCount"` but different `target` values. A snooper
comparing the two entries discovers that **the hidden achievement
shares the same metric as the public one but with a higher
threshold** — i.e. the trigger is "submit 5 bug reports".
---
This is an even-worse leak than the metric-name leak (C1) because
the cross-reference is trivial: open the page, see one public
bugreport badge with `target: 1`, and one hidden badge with
`metric: "bugReportCount"` and `target: 5` — game over for the
surprise.
## LOW
- **Recommendation**: Fix subsumed by C1 (don't ship `metric` or
`target` for hidden+locked entries). With C1 fixed, this leak goes
away.
### L1. `evictPerStatusInsightCache` Prisma query uses both `startsWith` and `AND[contains]`
- **File:** `src/app/api/insights/generate/route.ts:62-73`
- **Issue:** `where: { userId, action: { startsWith: "insights." }, AND: [{ action: { contains: "-status." } }] }`. The combined `AND` is redundant under "where AND" semantics — Prisma already AND's all top-level keys. The `AND` wrapper is needed only because the top-level `action` key already has a `startsWith`; piling another `action` filter inside an `AND[]` is the right pattern. Comment is good.
- **Recommendation:** Looks right, no change needed. Considered nit.
As a separate consideration: the design choice of reusing a public
metric for a hidden Easter-egg is questionable to begin with —
consider giving the hidden bug-buddy its own metric
(`bugReportCountHidden` or similar), so even server-side audit
trails don't accidentally reveal that "5 bug reports" is the
trigger via shared metric counting.
### L2. `evictPerStatusInsightCache` does not annotate the row count it deleted
- **File:** `src/app/api/insights/generate/route.ts:62-73`
- **Issue:** `deleteMany` returns `{ count }` which is dropped.
- **Recommendation:** Annotate `evicted_status_caches` so admin observability sees the eviction.
- **Ship-blocker?**: NO once C1 ships. Tracked under C1 fix.
### L3. `provider-runner` legacy `runRawCompletionWithFallback` is duplicated code
- **File:** `src/lib/ai/provider-runner.ts:312-371`
- **Issue:** Near-identical to `runWithFallback` (lines 229-288); diff is the inner `provider.generateCompletion` vs `generateInsight` call. The cache logic, fallbackHops shape, error classification, and Wide-Event annotation are duplicated.
- **Recommendation:** Extract a shared helper `runChain<T>(args, runOnce)` that takes the per-attempt callback. Defer if v1.4.17 is migrating the legacy route anyway.
### H4 — `getMoodMetrics` improvement-window slices distinct days, not 7 calendar days
### L4. `RecommendationsGrid` STAGGER_INTERVAL_MS = 100 ms — a 10-rec response takes 1s to fully animate in
- **File:** `src/components/insights/recommendations-grid.tsx:81`
- **Issue:** No upper bound on stagger total. Worst case 10 recs * 100 ms = 1s before last card lands.
- **Recommendation:** Cap with `min(index * 100ms, 600ms)` so late cards fade in immediately after the cascade-cap.
- **Severity**: HIGH (correctness — diverges from "7-day window" semantics)
- **File**: `src/lib/gamification/expansion-metrics.ts:104-138`
- **Issue**: `moodImprovementHit` slides a 7-distinct-day window
rather than a 7-calendar-day window:
### L5. `findClosestDia()` allows multiple sys to pair with the same dia
- **File:** `src/lib/analytics/bp-in-target.ts:80-100`
- **Issue:** Each sys picks its own closest dia independently. A burst of sys readings between two dia readings will all match the closer dia. Counts inflated for high-frequency sys-only logging.
- **Recommendation:** Acceptable for the 5-min-window pair-rule; closing pair after consumption would change semantics. Document or accept.
```ts
for (let i = 13; i < sortedDays.length; i++) {
const recent = sortedDays.slice(i - 6, i + 1); // 7 distinct days
const prior = sortedDays.slice(i - 13, i - 6); // 7 distinct days
...
}
```
### L6. `host-metrics-chart.tsx` `Intl.DateTimeFormat` recreated per `buildChartRows` call
- **File:** `src/components/admin/host-metrics-chart.tsx:93-97`
- **Issue:** Locale formatter is created inside the pure helper, but the helper itself is wrapped by `useMemo`. Each unique `data?.samples` reference triggers re-instantiation. Cheap per call, but wasteful.
- **Recommendation:** Move the formatter to module-scope.
If a user logs daily for a month, that's fine. But a user who logs
on April 1, 2, 3, 4, 5, 6, 7, then nothing for a month, then
May 1, 2, 3, 4, 5, 6, 7 will have the comparison fire across the
one-month gap — comparing two arbitrarily-spaced 7-entry sets, not
two contiguous 7-day windows.
### L7. `legacy-payload.ts` checks `rationale === undefined` AND `=== null` AND `typeof !== "object"` separately
- **File:** `src/lib/ai/legacy-payload.ts:46-52`
- **Issue:** `typeof rationale !== "object"` already returns true for undefined and null in JS. The early checks are belt-and-braces but redundant.
- **Recommendation:** Simplify to `if (rationale === null || typeof rationale !== "object") return true;` — `undefined` is also `typeof "undefined"` so the check covers it.
The doc-comment claims _"any 7-day window had a mean score at least
1.0 higher than the preceding 7-day window"_ (line 96) which is
**NOT** what the code does.
### L8. Comparison overlay `_compare` data keys cannot collide with metric types but use string concat
- **File:** `src/components/charts/health-chart.tsx:558`
- **Issue:** `merged[`${type}_compare`] = v` — relies on no metric type ending with `_compare`. Stable today, fragile to a future `WEIGHT_compare` enum value (unlikely but possible).
- **Recommendation:** Prefix with a non-identifier separator: `merged[`__compare__:${type}`]`. Or a `Map`. Acceptable as-is.
Practical impact: the achievement may unlock too eagerly for users
with sparse logging patterns. Not coercive (Marc's design rule is
satisfied), but the semantics are wrong.
### L9. `ai-section.tsx:1255` — `seededKey` joined with `,` from providerType, not stable JSON
- **File:** `src/components/settings/ai-section.tsx:1270`
- **Issue:** `chain.map((c) => c.providerType).join(",")` is derived from `chain`, which only changes when GET refetches. Same providerType-set in different order produces the same key — but if two reorderings happen before re-seed, the local entries diverge from the GET. Edge case; fine.
- **Recommendation**: Either:
- Update the doc-comment to say "compares the last 7 entries to the
7 preceding entries" and accept the simplification, OR
- Make the window calendar-day-based: build a daily array including
no-data days (or a `null` for missing), require a minimum number
of populated days in each window, then compute the means.
### L10. `host-metrics` route does NOT cache prev BigInt across HTTP calls
- **File:** `src/app/api/admin/host-metrics/route.ts:85-122`
- **Issue:** Per-request `prevReadBytes` walk; the first row in the returned window has `diskReadBps: null` because there's no previous sample to delta from. UI hides the line for that row. Documented behaviour. Fine.
The first option is cheaper and probably what Marc actually wants
("user is moving in the right direction"); the second is more
rigorous but adds complexity.
---
Either way, add a unit test for the cross-month-gap case to lock
in the chosen semantics.
## Cross-cutting
- **Ship-blocker?**: NO — the achievement still functions and it's
user-positive (not coercive). Document or fix in a follow-up.
### Plan-deviation observations
### H5 — `consistentMonthCount` keeps growing — wasted DB churn for once-fired achievement
- **B5b/B5c/B5d strict path is dark in production** — the production `/api/insights/generate` route still rides the `runRawCompletionWithFallback()` legacy shape. STATE.md acknowledges this as a "v1.4.17 migration" deferment, but the user-facing claims of "rationale required", "deterministic confidence", and "citation enforcement" are exercised only by tests. See **H3** above. Recommend explicit Marc decision before tagging v1.4.16: ship dark + adjust release-note language, OR migrate route to strict path.
- **A2 BD-Zielbereich predicate change is structurally sound** — `isBpReadingInTarget` ceiling+floor semantics centralised across all 6 call sites. Marc's stated 50% expectation is plausible given the `90 ≤ sys ≤ sysHigh AND 50 ≤ dia ≤ diaHigh` predicate; integration test `tests/integration/bp-in-target.test.ts` covers it. No drift risk.
- **B7 export endpoints** — well structured, share `export:<userId>` rate-limit bucket, audit-log on every download, no envelope-wrap on the file contents. `full-backup` round-trips through the canonical `BACKUP_SCHEMA_VERSION` shape so admin upload + restore accepts it. Clean.
- **Wave-C qemu-arm64 drop** — well-documented in `docker-publish.yml`. amd64-only is the right call given Marc's prod target. v1.5 path documented.
- **Severity**: HIGH (DB wear on long-tenured users)
- **File**: `src/lib/gamification/expansion-metrics.ts:175-182` and
`src/app/api/gamification/achievements/route.ts:601-610`
(persistence flow)
- **Issue**: `consistentMonthCount` returns the _total_ number of
consistent months over the user's entire history. The achievement
unlocks at target 1 (first consistent month), but the metric keeps
incrementing every month thereafter. Each new consistent month
causes:
1. Recompute on every `/api/gamification/achievements` call
(every 2 minutes via `<AchievementUnlockNotifier>` polling)
2. The `metrics.consistentMonthCount` column grows unboundedly
in the response payload (a small int, but conceptually unbounded)
3. No corresponding state in the UI exposes this count
### Tests + i18n parity
Compare to `moodEntryCount`, which does have multi-tier
achievements at 1 / 50 / 200 etc. — so the unbounded growth there
is justified.
- New SSR + integration tests are present for each new endpoint and for `RecommendationCard` rationale + confidence + feedback wiring.
- `recommendation-feedback.test.tsx` not located in the repo — STATE.md B5e claims +20 tests; the wire integration `tests/integration/insights-feedback.test.ts` covers the API but the component is exercised through the integration harness only, not isolated.
- i18n parity: B5a/B5e/B8 add EN+DE keys with parity tests. No drift detected.
- **Recommendation**: Either:
- Add tiered achievements at consistent-month 1 / 3 / 6 / 12 to
justify the growing count, OR
- Cap the count at the highest threshold (`min(count, 1)`) inside
`getEngagementMetrics()` to make the metric effectively boolean
and stop the growth.
---
Same review applies to `moodImprovementHit` (already capped at 1
by the `break;`), `nightOwlCount` (counted but no tiered targets
beyond 1), `earlyBirdCount`, `leapDayCount`, `doctorPdfCount`,
`localeFlipCount`. Consider capping all single-target hidden
metrics at 1 to limit the wire-payload growth and avoid a future
"did the user trigger this 100 times?" question that the data
doesn't actually answer.
## Severity rollup
- **Ship-blocker?**: NO. Pure efficiency / hygiene, no incorrect
user-facing behaviour.
- **CRITICAL:** 0
- **HIGH:** 8 (H1-H8)
- **MEDIUM:** 9 (M1-M9)
- **LOW:** 10 (L1-L10)
## MEDIUM / LOW
### M1 — Earnability flags treat `BLOOD_PRESSURE_DIA`-only users as having no BP data
- **Severity**: MEDIUM
- **File**: `src/lib/gamification/expansion-metrics.ts:74-83`
- **Issue**: `countMeasurementsByType` counts only `BLOOD_PRESSURE_SYS`
toward `bpCount`. If a user (or a sync source) ever wrote
`BLOOD_PRESSURE_DIA` rows without paired `BLOOD_PRESSURE_SYS` rows,
the BP achievements would not become earnable. The comment at
lines 26-28 of the test file explains the design choice ("avoid
double-counting") but doesn't justify why DIA-only data invalidates
the user from BP achievements.
- **Recommendation**: Either:
- `bpCount = max(sys, dia)` so DIA-only users still earn, OR
- Document the assumption "BP measurements always include a SYS
row" and add a guard that drops DIA-only writes upstream.
No action probably needed for v1.4.18 — Withings + manual entry
always pair them — but worth a backlog note.
- **Ship-blocker?**: NO.
### M2 — Hidden hint text is revealed in the unlock toast description
- **Severity**: MEDIUM
- **File**: `src/components/gamification/achievement-unlock-notifier.tsx:120-129`
- **Issue**: When a hidden achievement unlocks, the toast description
is `${t(achievement.titleKey)} — ${t(achievement.descriptionKey)}`
which combines the **real** title and description (e.g.
`"Night owl — Logged an entry between 02:00 and 04:00 in the morning."`).
This is correct _for the toast itself_ (the unlock IS the moment
to reveal the surprise). However, after the toast dismisses, the
notifier persists `seenIdsRef` so the hidden ID is no longer
treated as new. If the user switched their device clock back, or
cleared localStorage, the toast would re-fire — but that's an
acceptable edge case.
- **Recommendation**: No action needed. Behaviour is correct; flagging
here only because the brief asked the reviewer to look for hidden-
hint leakage. The unlock-toast reveal is by design.
- **Ship-blocker?**: NO.
### M3 — `/admin/api-tokens` mobile spec covers AdminShell but not SettingsShell
- **Severity**: MEDIUM (parity)
- **File**: `e2e/admin-api-tokens-mobile.spec.ts`
- **Issue**: A2 fixed the painted-scrollbar in BOTH `AdminShell` and
`SettingsShell` (same `no-scrollbar` utility). The Playwright
e2e regression guard at `e2e/admin-api-tokens-mobile.spec.ts` only
asserts the AdminShell path. SettingsShell has unit-level
coverage in `src/components/settings/__tests__/settings-shell.test.tsx`
(14 lines added) which checks the className. If `globals.css` ever
removes the `.no-scrollbar` utility, the unit test still passes
(it asserts the className, not the rendered scrollbar).
- **Recommendation**: Add a 5-line Playwright spec hitting
`/settings/profile` at Pixel-5 viewport asserting no horizontal
scroll. Mirror the AdminShell spec exactly.
- **Ship-blocker?**: NO.
### M4 — `chart-overlay-prefs` route doesn't annotate the previous values for audit trail
- **Severity**: LOW
- **File**: `src/app/api/dashboard/chart-overlay-prefs/route.ts:81-92`
- **Issue**: The `annotate()` call at the end captures the new flags
but not the previous ones, so a session log can't reconstruct the
full toggle history. Compare to `applyProfileUpdate` in
`src/lib/auth/profile-update.ts:89-94` which captures `from` /
`to` for the locale change.
- **Recommendation**: If audit forensics matters for chart overlay
prefs (probably not — Marc said this is a UX prefs blob not an
analytical attribute), capture both old and new flag sets in the
annotation. Otherwise drop this finding.
- **Ship-blocker?**: NO.
### M5 — `health-chart.tsx` no longer renders the personal-baseline ReferenceLine — confirm via grep
- **Severity**: LOW (verification — the test already enforces this)
- **File**: `src/components/charts/health-chart.tsx`,
`src/components/charts/__tests__/health-chart-overlay-defaults.test.tsx`
- **Issue**: The default-overlay test reads the source file and
asserts no unconditional baseline ReferenceLine. This is a brittle
source-string assertion (`readFileSync` + regex) that will break
if the file is reformatted or split. It's a reasonable
short-term guard, but it's not the same as testing the rendered
output.
- **Recommendation**: Replace with an SSR render assertion: render
`<HealthChart>` with default props, assert no element has
`data-slot="chart-personal-baseline-line"` (or whatever the
ReferenceLine's identity ends up being). Mirror the medication
chart's overlay-toggle test pattern.
- **Ship-blocker?**: NO.
### L1 — `parseDayKey` redundantly sets UTC hours after constructor
- **Severity**: LOW
- **File**: `src/lib/gamification/expansion-metrics.ts:230-232` (used
inside `getEngagementMetrics`)
- **Issue**: `parseDayKey` constructs the date with
`new Date(Date.UTC(year, month - 1, day, 12, 0, 0))` (already
noon UTC). The next line `cursor.setUTCHours(12, 0, 0, 0)` is a
no-op.
- **Recommendation**: Drop the redundant line.
- **Ship-blocker?**: NO.
### L2 — `de.json` / `en.json` symmetry not asserted for the v1.4.18 expansion strings
- **Severity**: LOW
- **File**: `messages/de.json`, `messages/en.json`,
`src/lib/__tests__/i18n*.test.ts` (per CLAUDE.md the symmetry
guard exists)
- **Issue**: Couldn't verify in this review whether the existing
i18n parity test enumerates the 22 new keys (mood × 3 + measurement
counts × 7 + engagement × 4 + hidden × 6 + hiddenCard placeholder
- hiddenUnlockToast + 2 category labels). The expansion adds ~30+
new keys; if the parity test is data-driven (read both files,
assert keys match), this is automatic. If it's hand-rolled, some
may have slipped.
- **Recommendation**: Quick sanity run of `pnpm test
src/lib/__tests__/i18n*` to confirm 0 failures. Already in CI most
likely.
- **Ship-blocker?**: NO.
## SUMMARY
- 1 CRITICAL (C1 — hidden achievement trigger leakage via API response)
- 5 HIGH (H1 race, H2 hook subscription, H3 bug-buddy metric leak [subsumed by C1], H4 mood-window semantics, H5 unbounded count growth)
- 7 MED/LOW (M1-M5, L1-L2)
Ship-blocker count: **1** (C1).
Recommended action before v1.4.18 release: scrub `metric` /
`titleKey` / `descriptionKey` / `target` / `current` /
`progressPercent` / `points` from hidden+locked entries in the API
response body. Add a route-level test that asserts hidden trigger
strings are not present in the response while the achievement is
locked, and ARE present once unlocked. Item H3 is auto-resolved by
the C1 fix.
The H1 race condition is real but masked in single-user usage; safe
to defer with a backlog entry. H2-H5 are correctness / hygiene
issues; defer to v1.4.19 absent regression reports. M/L items are
follow-on hygiene work.
Strong work overall — A1 BD-tile fix is clean and well-tested
(`computeBpInTargetWindows` reuses the v1.4.16 ceiling predicate
correctly with explicit null-handling for sparse data), A2 painted-
scrollbar fix correctly identifies that the same defect lives in
both shells and uses a single utility class to fix both, A3 chart
revert is thorough (all three reversions land + the toggle UI
persists per-chart cleanly), and B1 achievements expansion has
strong unit + integration test coverage for the visible cases. The
discovery filter design is correct. The remaining work is the wire-
shape hardening for the hidden category and the race-condition
guard for concurrent overlay toggles.
+445 -265
View File
@@ -1,265 +1,445 @@
# Phase D — DESIGN/UX review (v1.4.16, Apple-Health-quality benchmarking lens)
Reviewer: phase-D DESIGN/UX agent
Marathon: v1.4.16 (live: 1.4.15; under-test: 1.4.16)
Date: 2026-05-09 (UTC+2)
Approach: source-code audit (v1.4.16 not yet deployed; live Playwright not driven).
Per brief: "Apple Health benchmarking lens — does the polish reach the bar Marc set?"
---
## Executive summary
v1.4.16 ships a *huge* amount of code-level polish (gradient hero, ConfidenceMeter, RationaleCard with mini-charts, severity-coloured grid, comparison overlay, host-load chart, AI quality table). However, the **headline finding** is that the most visible Apple-Health-quality affordances — the polished `<InsightAdvisorCard>` consuming `<RecommendationsGrid>`, `<RecommendationCard>`, `<ConfidenceMeter>`, `<RecommendationFeedback>` — are **NOT mounted on any production page**. They live in tests only.
The v1.4.16 release as Marc would experience it on `/insights` is the same v1.4.15 text-only `<InsightStatusCard>` per section. The dashboard `/` likewise does not import `<InsightsCardPreview>`. **All of B5c, B5d, B5e, and B1b's cross-feature polish is invisible to the user.** This is the single biggest disconnect between the work that landed and the user's perception of the release.
A second category: the comparison toggle (B8) is *only* reachable from `/settings/dashboard-layout`. There is no on-chart, on-tile, or on-page control on `/` or `/insights`. The brief was explicit ("Mobile-friendly comparison toggle, not a fragile hover-only interaction") and the UX cost is severe: a v1.4.16 user has zero on-surface affordance to toggle Vormonat / Vorjahr.
Findings below grouped by severity.
---
## CRITICAL — ship-blockers for v1.4.16 (must fix before tag)
### C1 — `/insights` does NOT mount the polished `<InsightAdvisorCard>` / `<RecommendationsGrid>`. B5c/d/e/B1b are invisible
- **File:** `src/app/insights/page.tsx:1006-1539`
- **Issue:** every insights section renders `<InsightStatusCard>` (text-only summary, no rec card, no rationale, no confidence meter, no thumbs-feedback). The Apple-Health-style polish from B1b (`<InsightsPageHero>` is mounted, but it's just a header band) and the entire B5c/d/e rec-card surface (`<RecommendationCard>`, `<ConfidenceMeter>`, `<RecommendationFeedback>`, `<RationaleCard>`) is never reachable from any live route. Confirmed via `grep "InsightAdvisorCard"` — zero non-test imports across `src/`.
- **Recommendation:** wire `<InsightAdvisorCard>` into `/insights` (consume `/api/insights/generate`) **or** explicitly de-scope B5c/B5d/B5e/B1b from v1.4.16 release notes and tell Marc the visual polish ships in v1.4.17. The B5c/d/e reports each flag this gap ("E2E not added — not mounted on a live route") but none actually wire the card. Without wiring, Marc gets v1.4.15 UX with extra unused code paths.
- **Ship-blocker?** YES.
### C2 — `<InsightsCardPreview>` (dashboard insights tile) has zero live imports — orphan component
- **File:** `src/components/insights/insights-card.tsx:49`; `src/app/page.tsx` (no import)
- **Issue:** B1b commit 5 (`d2cdf9d`) replaced the orphan v1.4.0 `<InsightsCard>` with a polished preview that renders top severity-ordered recs + ring-variant `<ConfidenceMeter>` inline. The dashboard never imports it. `grep "InsightsCardPreview" src/app` — zero matches. The dashboard renders `RecentAchievementsCard` + tile strip + charts, no insights preview.
- **Recommendation:** mount `<InsightsCardPreview>` on `/` (the B1b report flagged this as "When dashboard wiring lands…") or hide the file and remove from v1.4.16 marketing claims.
- **Ship-blocker?** YES — Marc sees no insights tile on the dashboard despite the B1b report explicitly citing dashboard polish.
### C3 — Comparison toggle (B8) has NO on-surface control. Buried 3 clicks deep in Settings
- **File:** `src/components/settings/dashboard-layout-section.tsx:192-198` (only place `compareBaseline` is settable)
- **Issue:** Marc must navigate to /settings/dashboard-layout, scroll past 14 widgets, find the "Comparison Baseline" Select, change it, save, then back-navigate to / or /insights. The brief explicitly required "Mobile-friendly comparison toggle (not a fragile hover-only interaction)" — what shipped is a Settings-Save round-trip every time the user wants to flip from "vs. last month" to "vs. last year" or back to off. The B8 report's deferred-list mentions a `<CompareToggle>` segmented control was scoped but never built; only the persistence layer + chart-side rendering exist.
- **Recommendation:** add a `<CompareToggle>` (segmented "None / Vormonat / Vorjahr") next to the existing range tabs (7d/30d/90d/All) on each chart, OR a single page-level toggle pinned next to the dashboard greeting. Persists via the same `dashboardWidgets` PUT the settings page uses, no API change. Without it, the comparison-overlay code (compareBaseline plumbed through 19 chart sites + tile-strip + `comparison.deltaVs.*` i18n) is dead UX — admins can demo it, users can't find it.
- **Ship-blocker?** YES — major B8 deliverable is invisible without an on-surface toggle.
---
## HIGH — fix in v1.4.16 if cycle allows, or document for v1.4.17
### H1 — Recommendation feedback thumbs are 28×28 px hit-area — fails WCAG 2.5.5 (44 px floor)
- **File:** `src/components/insights/recommendation-feedback.tsx:268, 283`
- **Issue:** both thumb buttons use `inline-flex h-7 w-7 items-center justify-center rounded-md`. h-7 = 28 px. WCAG 2.5.5 minimum is 44×44 CSS px. Wave-C MED bottom-nav fix was specifically about 44 px floors; the same floor must apply to per-rec feedback that Marc taps on a Pixel 5. Also: zero `focus-visible:` styles on the raw `<button>` so keyboard focus is invisible.
- **Recommendation:** swap to `min-h-11 min-w-11` (or `min-h-9` at minimum + extra padding around an inner icon), add `focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2`. The brief asks "thumbs feedback button hit-area large enough" and the answer today is no.
- **Ship-blocker?** Borderline — Marc said 44×44 is the floor; any consumer accessibility audit will cite this.
### H2 — Recommendation card chevron expand toggle: 24×24 hit area, no focus-visible
- **File:** `src/components/insights/recommendation-card.tsx:368-385`
- **Issue:** raw `<button type="button" className="text-muted-foreground hover:text-foreground rounded-md p-1 transition-colors">` with a `<ChevronDown className="h-4 w-4">` inside. p-1 (= 4 px) + h-4 (= 16 px) gives ~24×24. Brief: "expand affordance discoverable; confidence meter readable; thumbs feedback button hit-area large enough." Current chevron fails the same 44 px bar as H1.
- **Recommendation:** wrap in `<Button variant="ghost" size="icon" className="h-9 w-9">` or hand-bump to `min-h-11 min-w-11`. Add `focus-visible:` ring (raw `<button>` has none).
- **Ship-blocker?** Same answer as H1. If H1 ships, H2 must too; feels broken if only one is fixed.
### H3 — Trend-card aria-label hardcoded English (i18n leak)
- **File:** `src/components/charts/trend-card.tsx:229-233`
- **Issue:** the comparison delta `aria-label` interpolates `"vs. last month"` / `"vs. last year"` as **literal English strings** regardless of the user's locale. Marc switching to DE gets a screen-reader announcing English while the visible text is German ("Δ −2.3 kg vs. Vormonat"). All other labels in the file use `t(...)`. Undocumented bypass.
- **Recommendation:** use the same `t(comparison.captionLastMonth/Year)` key the visible text consumes two lines below.
- **Ship-blocker?** No, but trivial fix.
### H4 — Medication-Compliance chart does NOT support `compareBaseline` overlay. Inconsistent with all other charts
- **File:** `src/components/charts/medication-compliance-chart.tsx`
- **Issue:** `grep "compareBaseline" medication-compliance-chart.tsx` — no prop. Every other chart family (BP/weight/pulse/mood) accepts `compareBaseline` and renders the dimmed prior-period line. The medication-compliance heatmap doesn't, so when the user toggles Vormonat the rest of the dashboard responds and only this card stays static. Not strictly a regression (B8 report flags compliance heatmap "doesn't shrink cleanly" and was deferred for the rec-card mini-mode), but the *toggle* still needs to be honoured visually somehow.
- **Recommendation:** add a one-line caption "Vergleich nicht verfügbar für Compliance" / "Comparison N/A for compliance" so the user understands the asymmetry rather than thinking the toggle is broken.
- **Ship-blocker?** No, but UX-incoherent without a fix.
### H5 — `via-dracula-cyan/8` Tailwind class — opacity `/8` is technically valid but visually nearly identical to bg; gradient appears flat on mobile
- **File:** `src/components/insights/insights-page-hero.tsx:81`
- **Issue:** `from-dracula-purple/15 via-dracula-cyan/8` — the via-stop at 8% opacity over a Dracula `--background` (#282a36) is a ΔL of ~3% from the page bg. On a Pixel 5 LCD in daylight, this looks like a flat purple band with no gradient. Apple Health hero gradients use 25-40% opacity at the brightest stop.
- **Recommendation:** bump to `from-dracula-purple/25 via-dracula-cyan/15` (commit 6 already raised opacity once; raise again or use inline CSS gradient where Tailwind's discrete steps clip the design).
- **Ship-blocker?** No, but the "Apple-Health-quality benchmarking" bar is what Marc set; this gradient does not meet it.
### H6 — Range-tabs (chart 7d/30d/90d/All) hit-area `min-h-9 px-2.5` = 36×~28 px. Below 44 px on Pixel 5
- **File:** `src/components/charts/health-chart.tsx:882`
- **Issue:** `className="min-h-9 px-2.5 text-xs"`. On the Pixel 5 viewport the user taps four narrow pills above each chart; missed-taps are routine. WCAG 2.5.5 again.
- **Recommendation:** swap to `min-h-11 px-3` or use a Radix `<Tabs>` primitive with a `min-h-[44px]` modifier (same as bottom-nav).
- **Ship-blocker?** No, but the brief calls out 44×44 explicitly.
### H7 — Medication CSV "include intake history" uses raw `<input type="checkbox">` 16×16 px. Mobile fail + visual inconsistency
- **File:** `src/components/settings/export-section.tsx:457-466`
- **Issue:** every other settings toggle is a `<Switch>` from `@/components/ui/switch`. The intake-history toggle is a vanilla `<input type="checkbox" className="border-border h-4 w-4">`. 16×16 hit area, breaks design-system consistency, and on iOS Safari renders as the system blue checkbox (jarring against Dracula).
- **Recommendation:** replace with `<Switch>` or shadcn `<Checkbox>`. Bump container to `<label className="flex min-h-11 …">`.
- **Ship-blocker?** No, but Marc's eye will catch the system-blue box on his iPhone immediately.
### H8 — DE fallback-chain row will overflow Pixel 5 (393 px) — 5 controls per row, all with German labels
- **File:** `src/components/settings/ai-section.tsx:1374-1434`
- **Issue:** each chain row is a `flex flex-wrap` with 5 controls (label hyperlink + Switch + ArrowUp + ArrowDown + X). EN labels are short (Move up / Move down / Remove from chain) but DE strings are longer ("Nach oben", "Nach unten", "Aus Kette entfernen") and the row uses `gap-2` so on 393 px it wraps to 2-3 lines per row. A 5-row chain becomes ~12 visual lines.
- **Recommendation:** at `<sm` breakpoint, hide aria-only textual labels on arrow buttons (icon is universal) and collapse the X into a long-press or "more" overflow menu. Or shrink each row to provider name + switch + drag handle, with reorder via popover.
- **Ship-blocker?** No, but renders unusably on Pixel 5 mobile in DE locale.
---
## MED / LOW — polish, parity, post-v1.4.16 cleanup
### M1 — App-log preview table uses `overflow-x-auto` with no mobile card fallback
- **File:** `src/components/admin/app-log-preview-section.tsx:207-263`
- **Issue:** B4 admin app-logs renders `<table>` inside `overflow-x-auto`. On Pixel 5 (393 px), 5 columns (level / timestamp / action / duration / trace_id) → user scrolls horizontally. A3 fix already swapped api-tokens to a card-list mobile fallback.
- **Recommendation:** add card-list rendering at `<md`. Mirror `<UserManagementSection>` pattern.
### M2 — RecommendationFeedback success state replaces row with text-only "Thanks" — no path to revoke or change verdict
- **File:** `src/components/insights/recommendation-feedback.tsx:212-225`
- **Issue:** once submitted, the row collapses to one line of text + check icon. There's no UI path to revoke or change the verdict. Server-side dedup is by `(user, recId, recText)` so a new attempt would 409, but the client-side UX gives no hint that this is final.
- **Recommendation:** keep the chosen thumb visible (highlighted / pressed-state) and add a quiet "Submitted — undo?" affordance. Even if not actually re-submittable, give the user the visual receipt of which thumb they chose. Apple Health's tendency: never collapse a state without an undo path.
### M3 — `<RecommendationsGrid>` border colors at `/70` opacity — not contrast-verified against `--card`
- **File:** `src/components/insights/recommendations-grid.tsx:64-69`
- **Issue:** `border-l-dracula-red/70` etc. The B1b commit-6 added a contrast-regression test for the page hero but not for the grid borders. At 70% opacity over `--card` (which is `--dracula-bg` slightly tinted), red border ratio is ~3.8:1 (passes 3:1 UI floor); purple is closer to 2.6:1 (likely fails). Severity is information-bearing.
- **Recommendation:** measure each token at `/70` against `--card`; bump under-3:1 ones to `/85`.
### M4 — ConfidenceMeter aria-label says "Vertrauen 75 von 100" — no band semantic
- **File:** `src/components/insights/confidence-meter.tsx:189-191`
- **Issue:** screen-reader announces only the raw integer. Better signal: "high confidence (75/100)" / "medium" / "low" / "draft". Apple's accessibility lead would call out the missing band — the visual ring color is the primary signal but blind users only get the number.
- **Recommendation:** include the band name in aria-label. New i18n key `confidenceAriaWithBand` taking `{value, band}` placeholders.
### M5 — InsightsPageHero hides "Generated" timestamp when no insight has been generated, but the hero band stays full-height — looks empty on first visit
- **File:** `src/components/insights/insights-page-hero.tsx:106-120`
- **Issue:** when `updatedAt` is null, the meta row collapses to a single "Based on your last 90 days" line. Hero remains visually heavy with no content density delta. First-time users see a giant gradient with two short text lines.
- **Recommendation:** when `updatedAt === null`, surface a "Generate your first insight →" inline CTA in the meta row. The regenerate button is already passed in via `onRegenerate` but appears top-right; promoting to inline-with-meta CTA would close the empty-state hole.
### M6 — Inconsistent severity color tokens between `<HeroFinding>`, `<RecommendationCard>`, `<RecommendationsGrid>`
- **Files:** `src/components/insights/insight-advisor-card.tsx:103-131` (HERO_STYLES) vs `src/components/insights/recommendation-card.tsx:49-56` (SEVERITY_BADGE_STYLES) vs `src/components/insights/recommendations-grid.tsx:64-69` (SEVERITY_BORDER_CLASSES).
- **Issue:** three separate style maps for the same severity vocabulary. Hero uses `green/cyan/orange/red` (assessment); rec-card uses `cyan/purple/orange/red` (severity); grid uses `red/orange/purple/cyan` (also reversed mental model). The hero & rec-card maps disagree about what "info" means (cyan in both, but the hero's "neutral assessment" is also cyan).
- **Recommendation:** centralise to `src/lib/severity-colors.ts`, expose helpers, have all three components consume them. v1.4.17 simplify-target.
### M7 — Sticky `<InsightsSectionNav>` + iOS Safari back-swipe gesture interference
- **File:** `src/app/insights/page.tsx:1695-1713`
- **Issue:** `flex gap-2` inside `overflow-x-auto`. On iOS Safari the back-swipe gesture competes with horizontal pan inside the sticky nav.
- **Recommendation:** add `overscroll-x-contain`.
### M8 — Host-metrics chart tooltip uses `formatter(value, name)` matching against `t(...)` outputs — i18n-fragile
- **File:** `src/components/admin/host-metrics-chart.tsx:278-298`
- **Issue:** `if (name === t("admin.hostMetrics.load1"))` — relies on string equality between Recharts' `name` prop and the localised label. If user mid-session swaps locale, formatter falls through and tooltip shows raw numbers without unit.
- **Recommendation:** key the formatter on a stable dataKey, not the localised name.
### M9 — Mood chart emoji glyphs (😖 🙁 😐 🙂 😄) — non-localizable, render differently per OS
- **File:** `src/components/charts/mood-chart.tsx` (B1a deliverable)
- **Issue:** emoji rendering varies between Apple, Noto Color Emoji, Segoe UI Emoji. Marc's iPhone vs Pixel 5 vs desktop Mac = three different visual weights for the same chart.
- **Recommendation:** v1.4.17 — replace emoji with custom SVGs (lucide has `Frown / Smile / Meh / Laugh / SmilePlus` that close the gap).
### M10 — `RecommendationCard` collapsed state has no visual hint that it's interactive
- **File:** `src/components/insights/recommendation-card.tsx:331-417`
- **Issue:** the chevron is the only affordance; the card border is identical for collapsed and expanded recs. Apple Health uses a subtle "tap to expand" caption + 1-px depth shadow. Oura's Contributors use a small "i" subtitle "Tap to see why". HealthLog: nothing.
- **Recommendation:** add `text-muted-foreground/60 text-[10px]` hint under rec text "Tap for details" / "Antippen für Details" on the collapsed state. Auto-disappears when expanded. Trivial; very high-leverage on first impression.
### M11 — Loading skeleton uses staggered `animationDelay` unconditionally — reduced-motion users still see staggered placeholders
- **File:** `src/components/insights/insight-advisor-card.tsx:340-348`
- **Issue:** `style={{ animationDelay: \`${i * 100}ms\` }}` is unconditional. With `prefers-reduced-motion: reduce`, the underlying `animate-pulse` is killed but the placeholders still appear in staggered fashion when their (already-stopped) animation would have fired.
- **Recommendation:** check `prefersReducedMotion()` in the JSX and skip the inline `animationDelay` when true.
### M12 — Insights page first-load: bare `Loader2` spinner, no skeleton shell mirroring final layout
- **File:** `src/app/insights/page.tsx:851-857`
- **Issue:** when `isLoading`, the page renders a centered spinner — no structure on first paint. Apple Health renders cached data immediately + spinner overlay; HealthLog could render `<InsightsPageHero updatedAt={null} />` + tile-strip skeleton + section-nav skeleton during the 2-second slow-API window.
- **Recommendation:** mount hero + skeleton tile strip during `isLoading` so the page has structure on first paint.
### M13 — `app-logs` empty state doesn't distinguish "buffer empty" from "filter matched nothing"
- **File:** `src/components/admin/app-log-preview-section.tsx:200-205`
- **Issue:** the EmptyState renders the same `admin.section.app-logs.empty` copy whether the buffer is genuinely empty (fresh process) or filters narrowed to zero. No CTA to clear filter.
- **Recommendation:** branch on `(traceId || actionFilter || level !== "__all__" || range !== "all")` to emit a "No matches — clear filters?" variant with reset button.
### M14 — Settings → Export `<input type="date">` is browser-locale-driven, not app-locale-driven
- **File:** `src/components/settings/export-section.tsx:248-284`
- **Issue:** `<Input type="date">` renders MM/DD/YYYY on US-locale browser even when app locale is DE. iOS opens a wheel picker (44 px) which is OK but the calendar icon overlaps long DE month names.
- **Recommendation:** v1.4.17 — wrap with `react-day-picker` (transitive shadcn dep) or shadcn Calendar+Popover for consistent locale rendering.
### M15 — `<HostMetricsChart>` Legend on mobile — non-issue at current padding; flagged for completeness
- **File:** `src/components/admin/host-metrics-chart.tsx:299-305`
- **Issue:** none. Recharts default Legend stacks fine on 393 px viewport.
### M16 — Onboarding tour after Wave A nav changes: anchors `nav-insights`, `nav-settings`, `nav-achievements` all still present
- **File:** `src/components/layout/sidebar-nav.tsx:74, 86, 462, 515`
- **Issue:** none — verified. A1's removal of admin sub-list expansion did not touch tour anchors.
- **Recommendation:** none.
### M17 — i18n parity for new B5d/B5e/B1b/B7 keys: spot-checked 17 keys; all present in EN+DE; no raw-key surfaces
- **Files:** `messages/en.json`, `messages/de.json`
- **Issue:** none.
- **Recommendation:** none.
### M18 — `<ConfidenceMeter>` ring variant `r=10` viewBox 28×28 — fits at every call site; non-issue
- **File:** `src/components/insights/confidence-meter.tsx:144-178`
- **Issue:** flagged for record.
### M19 — Dashboard tile delta callout stays muted on `neutral` direction-sentiment metrics — visually loses the "value did move" signal
- **File:** `src/components/charts/trend-card.tsx:162-172`
- **Issue:** intentional per design (neutral metrics shouldn't be celebrated/scolded), but visually loses the signal that the metric DID change. Apple Health uses ↑/↓ arrows in muted color for neutral metrics; HealthLog has the arrow on the trend-icon but not on the delta callout.
- **Recommendation:** add a small "↑" / "↓" glyph next to the muted delta on neutral metrics so the user reads "pulse moved up by 4 bpm" not "pulse showed −0".
### M20 — Severity Badge text uppercase but uses raw EN vocabulary ("URGENT" / "IMPORTANT" / "SUGGESTION" / "INFO") — not localised
- **File:** `src/components/insights/recommendation-card.tsx:354`
- **Issue:** `<Badge>{norm.severity}</Badge>` renders the raw enum value. EN-only. DE users see English vocabulary in a German UI.
- **Recommendation:** add `insights.recommendation.severity.{urgent,important,suggestion,info}` i18n keys. EN: same words. DE: "Dringend" / "Wichtig" / "Vorschlag" / "Info".
### M21 — `<ScatterCorrelationChart>` (correlation cards on /insights) does NOT receive B1a polish
- **File:** `src/components/charts/scatter-correlation-chart.tsx`
- **Issue:** B1a polish was scoped to time-series charts (HealthChart, MoodChart, MedicationCompliance). Scatter correlations on /insights still use the v1.4.0 visual style — abrupt visual delta when scrolling from BP timeline (polished) to BP-vs-weight scatter (legacy).
- **Recommendation:** v1.4.17 — extend ChartLinearGradient + RichChartTooltip to the scatter wrapper for visual coherence.
### M22 — `MedicationComplianceChart` 80% threshold + 100% goal lines + personal-baseline label may stack on the same y-position
- **File:** `src/components/charts/medication-compliance-chart.tsx`
- **Issue:** flagged from skim (A6 added 80% + 100% lines, B1a added baseline). Can't confirm without live render. Worth verifying once v1.4.16 deploys.
---
## Tally
- 3 CRITICAL (C1, C2, C3) — all three are "the polish shipped but isn't visible to the user". One coherent root cause: scope-completion-without-route-wiring.
- 8 HIGH (H1–H8) — accessibility (44 px hit areas, focus-visible, severity-uppercase i18n) + chart consistency (compareBaseline gap on med-compliance) + DE-locale fallback-chain row overflow
- 22 MED/LOW (M1–M22) — polish, second-pass parity, simplification opportunities for v1.4.17
Recommendation to release reconcile: prioritise **C1, C2, C3** above all H/M items. Without C1/C2/C3, v1.4.16 is internally a giant code release but externally a v1.4.15 + comparison-overlay-via-Settings + admin polish + AI-quality-table release. The headline "Apple Health–quality recommendations with confidence + thumbs feedback" is true *in the codebase* but not on Marc's screen.
If C1/C2/C3 cannot be wired in this cycle, the v1.4.16 release notes / Marc-Brief should be honest about it: phrase the polish as "groundwork for v1.4.17 user-facing rollout" rather than "the headline UX leap", and explicitly remove screenshots of the unmounted components from any marketing material.
---
## What I could not verify (Playwright not used)
The brief offered Playwright with Marc's session against `https://healthlog.bombeck.io` IF v1.4.16 was deployed. STATE.md shows v1.4.15 live, v1.4.16 phases complete but no E in tag-and-deploy. Source-code audit was the available approach. Specific live-render checks deferred:
- Actual gradient appearance on Pixel 5 LCD vs spec (H5)
- Tap-target measurements via DevTools mobile emulation (H1, H2, H6)
- Reduced-motion user-agent override on the staggered loading skeleton (M11)
- Sticky section-nav back-swipe interference on iOS Safari (M7)
- DE-locale fallback-chain row overflow on Pixel 5 (H8)
- MedicationComplianceChart label-stack live render (M22)
Once v1.4.16 deploys, a 30-minute Playwright pass against `cmox4d6fj000101p8w9ykhcnm` should validate or invalidate H5/H6/H8/M7/M11/M22.
# Phase D — DESIGN/UX review (v1.4.18)
Reviewer: design / UX (parallel run with 5 other reviewers)
Mode: source audit (production was on v1.4.17 at audit time, v1.4.18
not yet deployed; `/api/version` returned 1.4.17 at probe)
Branch state: origin/main pulled, 7 untracked stale files left in
place (4 v1.4.16-era export route dirs + 3 v1.4.16 phase-E reports);
none touch the surfaces under review.
Scope of audit:
- `/dashboard` charts after v1.4.18 A3 chart revert + per-chart toggle
- `/insights` (no regression check on RecommendationCard)
- `/admin/api-tokens` + other admin/settings sections (no-scrollbar
utility from A2)
- `/achievements` (B1 expansion + hidden cards)
- BD-tile 7T/30T sub-values (A1)
- A11y, contrast, focus, mobile-friendliness, EN/DE i18n parity
## What's clean (no findings)
- **Chart revert is solid.** No `linearGradient` defs, no `chart-gradient`
module (deleted), no emoji glyphs at mood data points (`MOOD_EMOJI`
map removed at `mood-chart.tsx:471` with explanatory comment), no
always-on personal-baseline reference line — the baseline is now
gated behind the `showTrend` toggle on both `HealthChart` and
`MoodChart`. Tests pin all three reverts (`health-chart-polish.test.tsx:146`,
`mood-chart-polish.test.tsx:66/81`, `medication-chart-polish.test.tsx:51`).
v1.4.16's smooth interpolation + rich tooltip + animation-on-render
are preserved as the brief required.
- **Default overlay state is OFF for all three toggles** —
`DEFAULT_CHART_OVERLAY_PREFS = { showTrendIndicator: false,
showTrendArrow: false, showTargetRange: false }` at
`chart-overlay-controls.tsx:165`. Pinned by
`health-chart-overlay-defaults.test.tsx`.
- **EN+DE i18n parity for new strings.** `chart.overlay.controls.{title,
trendIndicator,trendArrow,targetRange,tooltip.openSettings}` exist
in both `messages/en.json:652-664` and `messages/de.json:652-664`.
`achievements.{hiddenCard,hiddenUnlockToast,categories.hidden}` exist
in both at the same offsets.
- **Hidden-achievement DOM redaction works.** Locked hidden cards
render via the early-return path at `achievements/page.tsx:146-174`
with no `titleKey` / `descriptionKey` / `metric` / `target` / `current`
in the rendered output. Test pins:
`achievements/__tests__/page.test.tsx:252-262` asserts neither
"Night owl" nor "Logged an entry between 02:00 and 04:00" nor
"nightOwlCount" appears in the DOM. Once unlocked, real strings
appear (`page.test.tsx:264-269`).
- **No-scrollbar utility scope is appropriate.** Used only on the
intended `<nav>` strips in `<AdminShell>:160` and `<SettingsShell>:143`.
No accidental application elsewhere — `git grep no-scrollbar src/`
returns only the CSS definition + the two shells + their tests.
- **BD-Zielbereich 7T/30T sub-values now wire to real data.** Fields
`bpInTargetPct7d` / `bpInTargetPct30d` sourced from
`computeBpInTargetWindows()` flow through `dashboard/page.tsx:817-818`
into `<TrendCard>`. The `renderPair()` helper at
`trend-card.tsx:187-196` cleanly fall-throughs to `"—"` only when the
window genuinely has no paired readings.
- **Achievements API summary excludes hidden from `nextAchievement`.**
Route filters `!a.unlocked && !a.isHidden` at
`gamification/achievements/route.ts:835`, so the "Next goal" tile on
/achievements never accidentally points to a secret.
## CRITICAL
(none)
## HIGH
### H1 — `/insights` mood chart silently mounts the dashboard's overlay-controls dropdown
- **Severity:** HIGH (regression / cross-page state surprise)
- **File:** `src/components/charts/mood-chart.tsx:273` (default
`chartKey = "mood"`); usage at `src/app/insights/page.tsx:1479`
- **Issue:** `<MoodChart>` declares `chartKey = "mood"` as the default
prop value, so any consumer that omits the prop — including `/insights`
— picks up the dashboard's persisted overlay prefs **and** renders
the cog-settings dropdown next to the range tabs. Toggling
`showTrendArrow` from `/insights` immediately re-renders the
dashboard's mood chart with the same flag because the persistence
key is shared (`useChartOverlayPrefs("mood")`). The brief explicitly
scopes the new dropdown to "settings icon top-right of each chart"
on the dashboard, not /insights — `/insights` is a read-only insights
surface, not a per-chart preferences surface. Worse, `useChartOverlayPrefs`
fires its own `GET /api/dashboard/widgets` query on /insights mount
if the dashboard layout query isn't already in cache (deep-link to
/insights without first visiting /dashboard).
- **Recommendation:** Make `chartKey` an opt-in prop (no default) on
`MoodChart` to match `HealthChart`'s contract (`chartKey?:
ChartOverlayKey;` with no default, treated as "no toggles, no
persistence" when omitted). Then explicitly pass `chartKey="mood"`
only at the dashboard mount site (`page.tsx:982`). /insights
`<MoodChart>` should render a clean line with no cog (matching the
/insights `<HealthChart>` which already correctly omits chartKey).
- **Ship-blocker?** Yes if /insights is meant to stay read-only; no if
Marc wants overlay toggles cross-page (intent should be confirmed
before release).
### H2 — Cog-button tap target is 28×28 px (below WCAG 2.1 AA 44×44)
- **Severity:** HIGH (a11y / WCAG SC 2.5.5 Target Size, mobile UX)
- **File:** `src/components/charts/chart-overlay-controls.tsx:92`
- **Issue:** The settings-cog trigger uses
`className="text-muted-foreground hover:text-foreground h-7 w-7 px-0"`
→ 28×28 CSS px. The neighbouring range tabs in the same flex row
use `min-h-11 px-3` (44 px tall, ≥48 px wide). Both touch-target
policy and visual rhythm break: a thumb tap on a Pixel 5 (393 CSS
px wide) hits the surrounding tabs more reliably than the cog,
especially with tabs explicitly sized for 44 px. v1.4.16 phase D
reconcile (commit `c3451a4`) made this exact fix for the rec-feedback
/ range-tabs / export switch precisely to satisfy the 44 px rule;
the new cog regresses on it.
- **Recommendation:** Change to `min-h-11 min-w-11` (44 × 44) and keep
the icon size at `h-3.5 w-3.5` so the surrounding hit-area grows
without bloating the icon visual. Alternative: absolute-position the
cog over a 44-px hit-area outside the range-tabs row.
- **Ship-blocker?** Yes — Marc's a11y bar in v1.4.16 H1 was tap-target
fixes; shipping a known regression on the same rule undoes that work.
### H3 — Mood-chart header lacks `flex-wrap`; cog + 4 range tabs may overflow on narrow viewports
- **Severity:** HIGH (mobile layout regression)
- **File:** `src/components/charts/mood-chart.tsx:550`
- **Issue:** `<div className="flex items-center gap-1">` wraps the
4 range-tabs (each `min-h-11 px-3 text-xs`, ~40-50 px wide) plus
the new cog (`h-7 w-7`). Total minimum width ≈ 200-230 px on the
right side of the title row. Without `flex-wrap`, on a Pixel-5
viewport (393 CSS px) inside a Card with `p-6` padding (≈48 px
combined) the title group + bucket chip + comparison caption
competes with this fixed-width row. `<HealthChart>:887` and
`<MedicationComplianceChart>:273` both correctly use `flex-wrap`
in the same position; mood is the only outlier.
- **Repro:** Pixel-5 / 393 px viewport, mood chart visible, comparison
toggle ON ("Vormonat") → caption + tabs + cog overflow the card,
pushing layout horizontally and either clipping the cog or forcing
a horizontal scroll on the card.
- **Recommendation:** Add `flex-wrap` to the right-side container
matching the `HealthChart` / `MedicationComplianceChart` siblings:
`className="flex flex-wrap items-center justify-end gap-1"`.
- **Ship-blocker?** Yes for mobile users with comparison toggle on (a
common v1.4.16 surface). Soft-block otherwise.
## MED
### M1 — Achievements API leaks hidden-achievement strings via the network response
- **Severity:** MED (privacy / spoiler-discoverability via DevTools)
- **File:** `src/app/api/gamification/achievements/route.ts:852`
(default JSON), `:866-867` (iOS format also leaks)
- **Issue:** `applyDiscoveryFilter()` keeps hidden achievements in the
response (correct — the page renders an opaque card), but the wire
shape contains the full `AchievementProgress` for each:
`titleKey`, `descriptionKey`, `metric`, `target`, `current`,
`progressPercent`, `icon`. The DOM correctly redacts these (per H1
spec), but a curious user opening DevTools → Network → the response
JSON sees the trigger conditions verbatim ("Logged an entry between
02:00 and 04:00 in Berlin time" via the i18n key →
`messages/en.json` is also a public bundle). The brief says hidden
cards "do NOT leak title/description/conditions" — DOM is satisfied,
network channel is not. iOS clients additionally receive
`t(a.titleKey)` / `t(a.descriptionKey)` for hidden+locked entries
in the resolved-string `IosAchievement` shape, which is even more
exposed.
- **Recommendation:** For `isHidden && !unlocked`, return a redacted
shape: `{ id, isHidden: true, unlocked: false, points: 0, category:
"hidden", icon: "HelpCircle", titleKey:
"achievements.hiddenCard.title", descriptionKey:
"achievements.hiddenCard.description", target: 0, current: 0,
progressPercent: 0 }`. Once unlocked, send the real strings (the
reveal is the reward). Same for the iOS branch.
- **Ship-blocker?** No (the i18n strings are also in the public client
bundle so determined users can find them anyway), but the brief's
stated goal is "user knows they exist but not what they are" — this
is a textbook leak of "what they are".
### M2 — `recent-achievements-card.tsx` icon map is missing entries used by v1.4.18 expansion + hidden achievements
- **Severity:** MED (visual regression on dashboard recent-unlocks)
- **File:** `src/components/gamification/recent-achievements-card.tsx:32-47`
- **Issue:** `iconMap` covers 14 icons. Achievements added or referenced
in v1.4.18 (B1) use these icon strings the dashboard recent-unlocks
card cannot resolve and falls back to `Star`:
- `Smile` (3 mood badges: `mood-first`, `mood-streak-7`,
`mood-streak-30`)
- `Scale` (2 weight badges: `weigh-50`, `weigh-200`)
- `CalendarDays` (`consistent-month`)
- `Moon` (`hidden-night-owl`)
- `Sun` (`hidden-early-bird` and `mood-up-7`)
- `Sparkles` (`hidden-leap-day`)
- `FileText` (`hidden-doctor-pdf`)
- `Languages` (`hidden-locale-flip`)
The `/achievements` page (`achievements/page.tsx:53-76`) imports all
of these correctly. Result: a user who unlocks any of the new mood,
weight, engagement, or hidden-easter-egg achievements sees the
generic Star icon on the dashboard while the page itself shows the
intended icon — visual inconsistency between two surfaces of the
same feature.
- **Recommendation:** Mirror the page's iconMap into
`recent-achievements-card.tsx` (add the 8 missing icons), or extract
a shared `gamificationIconMap` module so both surfaces stay in sync
by definition.
- **Ship-blocker?** No; functional, just visually inconsistent.
### M3 — `PUT /api/dashboard/chart-overlay-prefs` has no idempotency guard or atomic merge
- **Severity:** MED (data-race + missing idempotency contract)
- **File:** `src/app/api/dashboard/chart-overlay-prefs/route.ts:41-91`
- **Issue:** Two parts:
1. Read-modify-write race. Lines 56-76 do
`findUnique(dashboardWidgetsJson) → resolveDashboardLayout → merge
prefs → user.update(...)`. Two concurrent toggles on different
charts (BP and weight) flipped within ~50 ms drop one update.
Single-user system today, but the v1.4 multi-tenant prep noted
in CLAUDE.md means this becomes a correctness issue once shared
household accounts ship.
2. Missing `withIdempotency()` wrapper. CLAUDE.md says all
POST/PUT/PATCH/DELETE handlers wrap in `withIdempotency()` so a
retried `Idempotency-Key` from the iOS app or a flaky network
replays the same response. The new route doesn't, so a network
blip during a toggle save can produce inconsistent state on
retry (the optimistic cache update + the rollback path in
`useChartOverlayPrefs` partially papers over this, but the
server-side guarantee is missing).
- **Recommendation:**
- Wrap with `withIdempotency()` (matches the contract every other
mutation route uses).
- Use a Prisma transaction OR push the merge into a `JSONB`-aware
update so the race is closed in the DB, not the application
process. PostgreSQL `jsonb_set` would satisfy this.
- **Ship-blocker?** No for v1.4.18 (single-tenant), but flag for
v1.4.19/v1.5 multi-tenant readiness (would be CRITICAL post
multi-tenant flip).
### M4 — Cog-button alignment with 44 px range tabs is visually noisy
- **Severity:** MED (design polish)
- **File:** `src/components/charts/chart-overlay-controls.tsx:92`,
`health-chart.tsx:887-911`, `mood-chart.tsx:550-570`,
`medication-compliance-chart.tsx:273-291`
- **Issue:** The cog (28 px tall) sits in the same flex row as the
range-tabs (44 px tall). Visually the cog reads as half-the-height
of its row neighbours, making it look like an afterthought rather
than a deliberate control. (This is the visual side of H2; H2 is
the a11y angle.) The icon also has `text-muted-foreground` whereas
the active range tab uses `bg-primary text-primary-foreground` —
the cog is so quiet it's easy to miss entirely on first paint.
- **Recommendation:** Once H2 is fixed (44 × 44), bump
`text-muted-foreground` → `text-foreground` (or use `variant="outline"`)
so the cog reads as a peer to the range tabs. Or swap to a more
obviously "settings" affordance — e.g. an `inline-flex` chip
labelled "Overlays" (matches Apple Health's "Show as" surface
pattern) so the user discovers the toggles without tap-and-pray.
- **Ship-blocker?** No; pure polish.
### M5 — Mobile section strip swipe affordance is now invisible (no scrollbar, no fade gradient)
- **Severity:** MED (discoverability regression from A2)
- **File:** `src/app/globals.css:217-223`,
`src/components/admin/admin-shell.tsx:160`,
`src/components/settings/settings-shell.tsx:143`
- **Issue:** A2 correctly killed the painted scrollbar Marc kept
reporting on `/admin/api-tokens`. Trade-off: there's no longer any
visual indication that the strip is horizontally scrollable. With
13 admin entries (≈1700 px) on a 393 CSS-px viewport, the user
sees the first 4-5 pills and has no cue that swiping right reveals
the rest. Native iOS / Android scroll-momentum hints (rubber-band)
appear only on touch, and a desktop user with no scrollbar simply
doesn't know to swipe.
- **Recommendation:** Add a right-edge fade gradient to hint
"more content this way" — common pattern for `no-scrollbar`
containers. CSS-only:
```css
.no-scrollbar-with-fade::after {
content: "";
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 24px;
background: linear-gradient(to right, transparent, var(--card));
pointer-events: none;
}
```
Apply to the strip parent. Or include a small chevron button on the
right edge that scrolls the strip programmatically.
- **Ship-blocker?** No (the strip is still functional via touch
swipe and arrow keys), but the discoverability hit means
/admin/{deeper-section} pages are essentially hidden from non-power
users.
### M6 — Hidden-category heading reveals the count of hidden achievements
- **Severity:** MED (mild spoiler)
- **File:** `src/app/achievements/page.tsx:421-424`
- **Issue:** The category header renders
`{unlockedInCategory} / {items.length}` — for the hidden category
on a fresh user this prints `0 / 6`, telling the user there are
exactly 6 hidden achievements waiting. Marc's brief said "user
knows they exist but not what they are"; the count borders on
"what they are" because users will start counting until they hit 6.
Some users may also feel pressure to unlock all 6, which arguably
conflicts with "playful, not health-coercive".
- **Recommendation:** For the hidden category specifically, suppress
the denominator and show only `{unlockedInCategory}` (or a `?` until
any are unlocked). Example: `{unlockedInCategory} / ?`
- **Ship-blocker?** No; can land in v1.4.19 polish.
## LOW
### L1 — `<MedicationComplianceChart>` has no `chartKey` plumbing through props but hard-codes `"medications"` internally
- **Severity:** LOW (architectural inconsistency)
- **File:** `src/components/charts/medication-compliance-chart.tsx:180`
- **Issue:** Unlike `HealthChart` (props `chartKey?: ChartOverlayKey`)
and `MoodChart` (default `chartKey = "mood"`), the medication chart
hard-codes `useChartOverlayPrefs("medications")` directly with no
prop. Any future ad-hoc render of the medication chart (rec-card
mini? insights?) automatically reads + writes the same persistence
slot, with no opt-out.
- **Recommendation:** Mirror HealthChart's contract: optional
`chartKey?: ChartOverlayKey` prop, default to `"medications"` when
used on the dashboard, omit on ad-hoc usage. Bonus: the cog can
then be hidden on usages where toggles don't make sense.
- **Ship-blocker?** No.
### L2 — Insights `/api/dashboard/widgets` ghost fetch when only a mood chart is mounted
- **Severity:** LOW (small bandwidth hit)
- **File:** `src/hooks/use-chart-overlay-prefs.ts:36-43`
- **Issue:** Side-effect of H1: every page that mounts a `<MoodChart>`
without explicit `chartKey={null}` triggers an extra
`GET /api/dashboard/widgets` fetch even though the page never uses
the layout for anything else. Negligible cost (TanStack dedupes
inside the same query client), but it's a hidden network call.
- **Recommendation:** Lands automatically when H1 lands.
- **Ship-blocker?** No.
### L3 — `chart-overlay-controls.tsx` Settings2 icon color is identical to `text-muted-foreground` on hover-disable
- **Severity:** LOW (minor contrast on hover-state regression)
- **File:** `src/components/charts/chart-overlay-controls.tsx:92`
- **Issue:** Trigger has
`className="text-muted-foreground hover:text-foreground"`. On
Dracula dark theme that's `--dracula-comment` → `--dracula-foreground`,
≈3.5:1 → ≈14:1. WCAG 2.1 AA passes for non-text icon (3:1
threshold), but the discoverability gap (M4) compounds with this
low default contrast.
- **Recommendation:** Use `text-foreground/70` → `hover:text-foreground`
(5:1 default, 14:1 hover).
- **Ship-blocker?** No.
### L4 — `chart-overlay-controls` dropdown content width (240 px) may clip on very narrow viewports near right edge
- **Severity:** LOW (radix collision detection mitigates most cases)
- **File:** `src/components/charts/chart-overlay-controls.tsx:101`
- **Issue:** `className="w-[240px] p-3"` with `align="end"
sideOffset={4}`. Radix's collision-detection re-aligns to start if
end-align would clip the viewport. On a 360 px viewport with the
cog near the right edge, the popover does drift left to fit, but
the labels (especially "Trend-Pfeil" → DE longer than
EN "Trend arrow") sit close to the right edge of the popover and
some users may misread "Trend-Pfeil" + the switch as one row vs.
two on the small screen.
- **Recommendation:** Confirm radix `collisionPadding` defaults are
in play; if not, add `collisionPadding={8}`. Alternatively, drop
the popover width to `min-w-[200px] max-w-[260px]` and let content
size it.
- **Ship-blocker?** No.
### L5 — `<HealthChart>` ad-hoc usage on `/insights` doesn't get cog (correct), but no cue tells the user "this chart's overlays are controlled from the dashboard"
- **Severity:** LOW (informational gap)
- **File:** `src/app/insights/page.tsx:1077`, `:1280`, `:1444`, `:1601`
- **Issue:** `/insights` BP/weight/pulse charts intentionally don't
pass `chartKey`, so they render clean lines with no toggle. A user
who finds the dashboard cog and turns on "Trend-Pfeil" expects the
same treatment on /insights — it doesn't show up. No tooltip or
caption explains the difference.
- **Recommendation:** Either (a) plumb the same chartKey through so
the user sees consistent overlay state across both pages — but
that conflicts with the read-only insights aesthetic, OR (b) add
a small caption to /insights charts explaining that overlays are
set per-dashboard-card.
- **Ship-blocker?** No; informational.
## Notes for Wave-D reconcile
- **H1 + H2 + H3 are the only ship-blockers from a design lens**
(assuming the brief's "settings icon top-right of each chart"
scopes to the dashboard, which is how I read it).
- **All other findings are polish / consistency** that can either
land in this release if context permits, or roll into v1.4.19
/ v1.5 backlog.
- **No CRITICAL.** The chart revert lands cleanly, the achievements
page is well-built, and A2's no-scrollbar fix isn't fighting
legitimate scroll affordances anywhere else in the app.
- **Achievements network leak (M1)** is worth a quick fix even at
MED — the i18n strings ARE in the public client bundle so a
determined user finds them anyway, but the spirit of "hidden"
argues for the redacted-on-wire shape.
## Surfaces verified
- `src/components/charts/chart-overlay-controls.tsx`
- `src/components/charts/health-chart.tsx` (lines 289-1339)
- `src/components/charts/mood-chart.tsx` (lines 268-849)
- `src/components/charts/medication-compliance-chart.tsx`
- `src/hooks/use-chart-overlay-prefs.ts`
- `src/lib/dashboard-layout.ts` (lines 105-287)
- `src/app/api/dashboard/chart-overlay-prefs/route.ts`
- `src/app/api/gamification/achievements/route.ts`
- `src/app/achievements/page.tsx`
- `src/app/page.tsx` (chart wiring lines 870-1046, BD-tile 800-825)
- `src/app/insights/page.tsx` (chart usage 1077, 1280, 1444, 1479, 1601)
- `src/components/gamification/recent-achievements-card.tsx`
- `src/components/gamification/achievement-unlock-notifier.tsx`
- `src/components/admin/admin-shell.tsx` (no-scrollbar)
- `src/components/settings/settings-shell.tsx` (no-scrollbar)
- `src/app/globals.css` (no-scrollbar utility, lines 195-223)
- `messages/en.json` + `messages/de.json` (chart.overlay.controls._,
achievements.hidden_, achievements.categories.hidden parity)
## Test surfaces verified
- `src/components/charts/__tests__/chart-overlay-controls.test.tsx`
(3 cases — labels, defaults, onChange contract)
- `src/components/charts/__tests__/health-chart-overlay-defaults.test.tsx`
(default-OFF SSR pin)
- `src/components/charts/__tests__/health-chart-polish.test.tsx`
(no linearGradient defs, no chart-gradient)
- `src/components/charts/__tests__/mood-chart-polish.test.tsx`
(no linearGradient, no mood-emoji-glyph)
- `src/components/charts/__tests__/medication-chart-polish.test.tsx`
(no linearGradient)
- `src/app/achievements/__tests__/page.test.tsx`
(hidden DOM redaction, hidden card data-slot, EN+DE category
heading, locale-localized "Versteckt")
- `src/components/admin/__tests__/admin-shell.test.tsx`
(no-scrollbar class assertion)
- `src/components/settings/__tests__/settings-shell.test.tsx`
(no-scrollbar class assertion)
End of design findings.
+57 -51
View File
@@ -6,8 +6,8 @@ Status: pre-release of v1.4.16; v1.4.15 live since the night before.
This is the strategic lens. The other reviewers (code, security, design,
senior-dev, simplify) are landing CRITICAL/HIGH inline. I am writing
this for the question Marc-three-weeks-from-now will ask: *what state is
the app in, and what does v1.5 actually need to look like?*
this for the question Marc-three-weeks-from-now will ask: _what state is
the app in, and what does v1.5 actually need to look like?_
---
@@ -146,7 +146,7 @@ The 8 things that moved the needle:
This is where the strategic decisions live. v1.4.x has been
incremental hardening; v1.5 is the next minor and should ship the
things that *change what the product is*.
things that _change what the product is_.
### C.1 Coolify image-digest auto-deploy
@@ -204,9 +204,11 @@ view.
**Dependencies.** B8 (shipped).
### C.5 AI: streaming responses + prompt-version A/B + per-user
fine-tune-equivalent
**What.** Three sub-items, sized for v1.5:
- **Streaming.** SSE/`ReadableStream` from `/api/insights/generate`
so the user sees the summary text appear progressively. Codex
already streams via SSE on the wire — we currently buffer. Effort
@@ -219,11 +221,11 @@ fine-tune-equivalent
prompt prefix derived from the user's accepted feedback (e.g.
"this user dismisses sleep-related recs; emphasise BP and weight
trends instead"). Effort M.
**Why now.** B5e already collects the signal; B5b already
attributes by provider; the loop is a half-circle without these.
**Importance.** Differentiator (combined: this is what no
consumer-grade health app does).
**Dependencies.** C.3.
**Why now.** B5e already collects the signal; B5b already
attributes by provider; the loop is a half-circle without these.
**Importance.** Differentiator (combined: this is what no
consumer-grade health app does).
**Dependencies.** C.3.
### C.6 Charts library decision
@@ -232,21 +234,23 @@ quality bar (B8 shipped a `ComposedChart` with two `<Line>` paths and
a custom dimmed-stroke prop, but the second-axis tooltip + per-segment
opacity is already painful). Decide v1.5: stay on Recharts and keep
patching, or swap to a library with first-class gradient + animation
+ multi-series (Visx, Tremor, Apache ECharts via React-EChartsCore).
**Why now.** Every future chart feature (yearly summary, Apple Health
import overlay, longitudinal correlation lines) hits the same
ceiling. Decide before adding the next 3 charts.
**Effort.** L (full chart wrapper rewrite, ~12 chart files).
**Importance.** Enabler. The decision either unlocks or constrains
every visual roadmap item below.
**Dependencies.** B1a/B1b primitives (`chart-gradient`, `chart-
- multi-series (Visx, Tremor, Apache ECharts via React-EChartsCore).
**Why now.** Every future chart feature (yearly summary, Apple Health
import overlay, longitudinal correlation lines) hits the same
ceiling. Decide before adding the next 3 charts.
**Effort.** L (full chart wrapper rewrite, ~12 chart files).
**Importance.** Enabler. The decision either unlocks or constrains
every visual roadmap item below.
**Dependencies.** B1a/B1b primitives (`chart-gradient`, `chart-
tooltip`, `chart-empty-state`, `health-chart`) define the contract a
new library has to satisfy. The prop surface is small.
new library has to satisfy. The prop surface is small.
### C.7 iOS native client API contract freeze
**What.** Lock the v1 contract for the separate
`~/Projects/healthlog-iOS` repo. Specifically:
- `POST /api/auth/login` + passkey-login-verify (existing).
- Token rotation contract (existing — but document the 24h access /
90d refresh defaults so iOS doesn't surprise-fail at 24h+ε).
@@ -259,11 +263,11 @@ new library has to satisfy. The prop surface is small.
on reconnect. iOS will need it; today it would loop unary calls.
- **Missing:** Versioned API path (`/api/v1/`) so v1.5 can break v2
without breaking the shipped iOS app.
**Why now.** Native client work has to happen against a stable
contract. Today everything is unversioned.
**Effort.** M (versioned router + bulk endpoint + contract doc).
**Importance.** Enabler for the iOS investment.
**Dependencies.** None blocking; clean to do.
**Why now.** Native client work has to happen against a stable
contract. Today everything is unversioned.
**Effort.** M (versioned router + bulk endpoint + contract doc).
**Importance.** Enabler for the iOS investment.
**Dependencies.** None blocking; clean to do.
### C.8 Security hardening sweep
@@ -276,12 +280,13 @@ contract. Today everything is unversioned.
with v1.4.16 host-metric sampler at 1-row-per-minute we'll hit
storage growth in months, not years. Add a configurable retention
with a worker job that archives older rows to S3. Effort M.
**Why now.** Audit-log + host-metric tables are growing 24×60 rows
per day each. Compute the year-out size; act before it bites.
**Importance.** Tech debt.
**Dependencies.** None.
**Why now.** Audit-log + host-metric tables are growing 24×60 rows
per day each. Compute the year-out size; act before it bites.
**Importance.** Tech debt.
**Dependencies.** None.
### C.9 Performance — Recharts deferred bundle, SSR insights,
read-replica
- **Recharts deferred bundle:** v1.4.14 saved 108 KiB on `/insights`.
@@ -293,35 +298,36 @@ read-replica
- **Prisma read-replica.** Adapter API supports it. The bottleneck
isn't writes; it's the bucket-series read on `/insights` and
dashboard. Effort M.
**Why now.** v1.4.16 added the host-load chart to admin which makes
real perf data visible; if I don't act on it, why did I add it.
**Importance.** Tech debt + UX.
**Dependencies.** C.6 charts decision (if Recharts goes, so does the
deferred-bundle work).
**Why now.** v1.4.16 added the host-load chart to admin which makes
real perf data visible; if I don't act on it, why did I add it.
**Importance.** Tech debt + UX.
**Dependencies.** C.6 charts decision (if Recharts goes, so does the
deferred-bundle work).
### C.10 Cross-device backup encryption + S3 push
**What.** `BACKUP_S3_*` env block is documented in CLAUDE.md (PutObject
+ GetObject, retention via bucket lifecycle) but the actual S3-push
worker is **not wired**. Wire it; encrypt with the same versioned
encryption-keys mechanism (different DEK per backup, KEK rotation via
the existing `rotate-encryption-key.ts` script). Add download-from-
S3 + restore-from-S3 to `/admin/backups`.
**Why now.** Today a host disk failure loses the last week of
backups. Sunday-snapshot is local-only.
**Effort.** M.
**Importance.** Differentiator (true off-host backup, encrypted, with
a published restore path) + GDPR-data-portability story.
**Dependencies.** None.
- GetObject, retention via bucket lifecycle) but the actual S3-push
worker is **not wired**. Wire it; encrypt with the same versioned
encryption-keys mechanism (different DEK per backup, KEK rotation via
the existing `rotate-encryption-key.ts` script). Add download-from-
S3 + restore-from-S3 to `/admin/backups`.
**Why now.** Today a host disk failure loses the last week of
backups. Sunday-snapshot is local-only.
**Effort.** M.
**Importance.** Differentiator (true off-host backup, encrypted, with
a published restore path) + GDPR-data-portability story.
**Dependencies.** None.
### C.11 Integrations — Apple Health, Garmin, Oura
This is the *strategic* roadmap item.
This is the _strategic_ roadmap item.
- **Apple Health import.** The native iOS app is the natural pipe;
parse XML export (`export.xml` from Health.app) and POST to
`/api/measurements/bulk` (see C.7). Effort M (just XML parse + map
+ bulk endpoint).
- bulk endpoint).
- **Garmin Connect.** OAuth + sync service mirroring
`src/lib/withings/`. Effort M.
- **Oura.** OAuth + their API is JSON-clean; the metric mapping is
@@ -334,7 +340,7 @@ B5c was inspired by it — natural completion of the loop), then
Garmin if there's still a v1.5 budget.
**Importance.** Differentiator. HealthLog with Withings-only is a
tracker; HealthLog ingesting Apple Health + Oura is *a hub*.
tracker; HealthLog ingesting Apple Health + Oura is _a hub_.
**Dependencies.** C.7 bulk endpoint + versioned API.
### Honest sizing
@@ -343,20 +349,20 @@ tracker; HealthLog ingesting Apple Health + Oura is *a hub*.
- **1-month items:** C.3 prompt-tuning ratchet, C.5 streaming + A/B,
C.7 iOS contract freeze + bulk endpoint, C.9 SSR insights, C.10 S3
backup push, C.11a Apple Health import.
- **Quarter-investment:** C.6 chart library swap (do this *first* if
- **Quarter-investment:** C.6 chart library swap (do this _first_ if
doing it at all — block other chart work behind the decision), C.5
per-user prompt prefix (needs ~60 days of feedback collection
first), C.11b Oura full integration.
If Marc has 6 weeks of focus for v1.5: ship C.1 + C.2 + C.4 + C.7 +
C.10 + C.11a Apple Health. That is a release that fundamentally
changes what HealthLog is *to a user*.
changes what HealthLog is _to a user_.
---
## D. Follow-on initiatives (v1.6+)
Things v1.4.16 *unlocked* but aren't natural v1.5:
Things v1.4.16 _unlocked_ but aren't natural v1.5:
1. **Compliance-export admin tool** off the v1.4.16 B4 audit-log
filter API. Same filter shape, output is a signed PDF + CSV bundle
@@ -394,7 +400,7 @@ Things v1.4.16 *unlocked* but aren't natural v1.5:
rationale carries `dataWindow` + `comparedTo` + `deviation` per
rec. Run the same `confidence.ts` math client-side on the most
recent N points and surface "today's 7-day window is trending
toward an out-of-target reading" *without* an LLM call. Local,
toward an out-of-target reading" _without_ an LLM call. Local,
private, instant.
7. **Public-facing API for self-export.** v1.4.16's bearer-token +
@@ -414,7 +420,7 @@ Things v1.4.16 *unlocked* but aren't natural v1.5:
2. **Recharts ceiling.** B8 already needed a custom dimmed-stroke
prop. The next chart feature (multi-axis, stacked-area-with-band,
small-multiples grid) will be the breaking point. Decide the
library question (C.6) *before* the next chart commit lands.
library question (C.6) _before_ the next chart commit lands.
3. **In-memory app-log buffer is per-process.** v1.4.16 B4 ships a
500-entry FIFO. With `HEALTHLOG_PROCESS_TYPE=worker` the worker's
@@ -467,5 +473,5 @@ HealthLog is no longer "Marc's weekend tracker with a clever passkey
flow" — it's a personal-health-data hub with a citation-grounded AI
that I would actually show a doctor, and v1.5 is the release where it
either picks up the iOS app + Apple Health + off-host backup story
and becomes a *product*, or stays a very polished tool that only one
and becomes a _product_, or stays a very polished tool that only one
person uses.
+90 -132
View File
@@ -1,152 +1,110 @@
# Phase D — Reconcile report (v1.4.16)
# Phase D — Reconcile report (v1.4.18)
Reviewer: phase-D reconcile agent (sequential after the 6 parallel
reviewers + Product-Lead).
Inputs: `phase-D-{code-review,security,design,senior-dev,simplify,
product-lead-review}-findings.md`.
Constraint: charts data-presentation can change in v1.4.16; visual
style stays Dracula. No new dependencies. Pre-commit hooks must run;
no `--no-verify`, no `--no-gpg-sign`.
Date: 2026-05-10
Scope: act on the 6 reviewer briefs (code-review, security, design,
senior-dev, simplify, Product-Lead). Apply the 1 CRITICAL,
land HIGH where scope allows, run the simplify-yes batch, defer
the rest. Verify all gates green before tag.
Verdict: 1 of 3 CRITICAL fixed inline (C3 on-surface comparison
toggle). 2 of 3 deferred to v1.4.17 with documented rationale.
9 HIGH fixed, 12 deferred. 7 of 8 simplify-yes applied.
## CRITICAL — status: FIXED
---
**C1 — Hidden-achievement trigger leakage in API response.**
The opaque-card UI was correct, but the JSON wire shape leaked
`metric`, `titleKey`, `descriptionKey`, `icon`, `target`,
`progressPercent`, plus the hidden-only metric counters. A user with
DevTools open could read the trigger semantics for every locked
Easter-egg. The iOS branch was strictly worse — server-side
resolved the i18n keys to plain text.
## CRITICAL — status per finding
Commit `545f44c` — `fix(achievements): redact hidden-locked entries
from API response`. Adds `redactIfHiddenLocked()` and
`redactHiddenMetrics()` helpers in
`src/app/api/gamification/achievements/route.ts`. Hidden+locked
entries project to a sentinel shape (`achievements.hiddenCard.title`
key, `HelpCircle` icon, `metric: "totalTakenIntakes"`, target/current
0). Hidden-only metric counters (`nightOwlCount`, `earlyBirdCount`,
`leapDayCount`, `doctorPdfCount`, `localeFlipCount`) are dropped from
the `metrics` block unless the matching achievement is unlocked. The
iOS branch reuses the same redacted view.
### C1 — `/insights` does NOT mount the polished `<InsightAdvisorCard>` / grid / RecCard / ConfidenceMeter / Feedback. **DEFERRED**
5 new tests in
`src/app/api/gamification/achievements/__tests__/hidden-redaction.test.ts`
guard the wire shape. Existing 53 gamification tests stay green.
Wider-than-reasonable-scope-tonight. The `/insights` page consumes 7
distinct per-status endpoints (`/api/insights/{general,blood-pressure,
weight,pulse,bmi,mood,medication-compliance}-status`) returning text-
only payloads. Mounting `<InsightAdvisorCard>` requires either
rewriting all 7 status endpoints to return the
rationale+confidence+citation rich shape, OR migrating the page to
consume `/api/insights/generate`'s comprehensive payload directly.
Same v1.4.17 migration the senior-dev review and code-review (H3)
flagged.
## HIGH triage
**Workaround applied:** v1.4.16 release notes / Marc-Brief MUST phrase
the polish components as "groundwork for v1.4.17 user-facing rollout"
rather than headline UX. Screenshots of the unmounted advisor card
surface do NOT belong in v1.4.16 marketing material. Logged to
`v15-backlog.md` from-design section.
| Finding | Source | Fix / Defer | Commit |
|---|---|---|---|
| H1 — chart-overlay-prefs R-M-W race | code-review | FIXED | `cf75579` |
| H2 — `useChartOverlayPrefs(chartKey ?? "bp")` waste | code-review | FIXED via simplify F2 | `720e6c8` |
| H3 — bug-buddy shared metric leak | code-review | AUTO-RESOLVED by C1 | `545f44c` |
| H4 — mood-window doc/code mismatch | code-review | FIXED (doc clarified) | `c6e3ac6` |
| H5 — consistentMonthCount unbounded | code-review | FIXED (capped at 1) | `c6e3ac6` |
| HIGH-1 — same as C1 | security | AUTO-RESOLVED by C1 | `545f44c` |
| HIGH-2 — i18n bundle leak | security | DEFERRED to v1.4.19 | bundle-strip needs build hook |
| H1 design — mood `chartKey="mood"` default | design | FIXED | `fbf14fc` |
| H2 design — cog 28x28 to 44x44 | design | FIXED | `194ec2f` |
| H3 design — mood-chart header `flex-wrap` | design | FIXED | `fbf14fc` |
### C2 — `<InsightsCardPreview>` (dashboard insights tile) has zero live imports. **DEFERRED**
8 of 10 HIGH fixed inline. 1 deferred (HIGH-2 security — i18n
bundle architectural change, doesn't fit reconcile scope). 1
auto-resolved (HIGH-1 security = C1; H3 code = subsumed by C1).
Same root cause as C1. Mounting on `/` requires a new GET endpoint
exposing `User.insightsCachedText` + cache-aware staleness markers
(the existing `/api/insights/generate` is POST mutation-only).
Logged to `v15-backlog.md`.
## Simplify-yes (7/7 applied)
### C3 — Comparison toggle has NO on-surface control (buried 3 clicks deep). **FIXED**
Combined commit `720e6c8` — `refactor(v1.4.18): apply simplify-review
safe suggestions`:
Commit `6e74d38` `fix(design): on-surface comparison toggle on
dashboard + insights (C3)`. New `<CompareToggle>` segmented control
(None / Vormonat / Vorjahr) mounted next to dashboard greeting and
on `/insights` page hero. Persists via the same
`/api/dashboard/widgets` PUT the Settings section uses, with
optimistic update + cache invalidation. 44 px tap targets,
focus-visible ring, role=group + aria-label, EN+DE i18n. 6 SSR
tests pin segment count, persisted-active state, tap-target floor,
EN+DE labels, and ARIA shape.
- **F1** — Drop duplicate `ChartOverlayPrefsValue` type + DEFAULT in
`chart-overlay-controls`; re-export the canonical pair from
`dashboard-layout`. Test imports updated.
- **F2** — `useChartOverlayPrefs(chartKey: ChartOverlayKey | null |
undefined)` short-circuits when no key is supplied.
`health-chart` drops the three discard-the-result ternaries.
- **F3** — `/api/analytics` no longer pair-and-counts BP twice; the
windowed helper produces both the headline and the sub-values.
- **F4** — Drop the redundant `moodEntries.map(...)` reshape.
- **F5** — Merge the five hidden-metric defensive cases into the
no-precondition block of `isEarnable`.
- **F6** — Delete the deprecated `getAchievementCategory` wrapper
(zero callers).
- **F7** — Strip six "v1.4.18 gradient removed" what-comments.
---
## HIGH — triage table
| # | Source | Finding | Action | Commit |
| --- | ----------- | -------------------------------------------------- | ---------------------------------------------------------- | --------- |
| H1 | code-review | feedback-aggregator bucket-key collision | **already-fixed** (reviewer mis-read; SOH `\x01` in code) | n/a |
| H2 | code-review | FallbackChainCard discards `enabled` flag | **fixed** | `5f7b9d8` |
| H3 | code-review | Strict B5b/c/d AI features dark in production | **deferred** → v1.4.17 (route migration) | n/a |
| H4 | code-review | findRecommendationsMissingRationale always-empty | **deferred** → v1.4.17 | n/a |
| H5 | code-review | feedback endpoint had no rate-limit | **fixed** | `6863ecb` |
| H6 | code-review | feedback over-broad cache invalidation | **fixed** | `6863ecb` |
| H7 | code-review | comparison overlay shifts current data forward | **deferred** (needs upstream-fetcher audit) | n/a |
| H8 | code-review | seedKey race in ai-section | **deferred** | n/a |
| H1 | security | meta + action.details not redacted at admin egress | **fixed** | `fb12f09` |
| H2 | security | `/api/admin/audit-log` raw `details` returned | **fixed** | `fb12f09` |
| H1 | design | rec-feedback thumbs 28×28 px | **fixed** | `c3451a4` |
| H2 | design | rec-card chevron 24×24 px | **fixed** | `c3451a4` |
| H3 | design | trend-card aria-label hardcoded English | **fixed** | `2f057f4` |
| H4 | design | medication-compliance no compareBaseline overlay | **fixed** (caption added) | `5661439` |
| H5 | design | InsightsPageHero gradient too faint | **deferred** → v1.4.17 visual sweep | n/a |
| H6 | design | Chart range tabs 36×~28 px | **fixed** | `c3451a4` |
| H7 | design | Medication CSV raw `<input type="checkbox">` | **fixed** (Switch + min-h-11) | `c3451a4` |
| H8 | design | DE fallback-chain row Pixel-5 overflow | **deferred** (premise partly mistaken — buttons icon-only) | n/a |
| H1 | senior-dev | `src/lib/ai/` 19 flat files architectural pressure | **deferred** → v1.4.17 | n/a |
| H2 | senior-dev | `<InsightAdvisorCard>` 690-line god-component | **deferred** → v1.4.17 | n/a |
| H3 | senior-dev | `<RecommendationCard>` approaching size | **deferred** → v1.4.17 | n/a |
**Tally: 9 HIGH fixed, 1 already-fixed (mis-read), 11 deferred to v15-backlog.md.**
---
## Simplify-yes — applied
7 of 8 apply-yes findings landed in commit `f3025a8`
`refactor(v1.4.16): apply simplify-review safe suggestions`:
- F2 — `confidenceContext` config-bag dropped from `generateInsight()`.
2 confidence-wrapper tests rewritten to test parsed-payload path.
- F3 — `aiRecommendationRationaleSchema.referenceId` speculative
optional dropped.
- F4 — `applyLastWorkingCache` doc-comment trimmed.
- F5 — `<RecommendationFeedback>` re-uses canonical
`RecommendationFeedbackRequest` types.
- F6 — `formatRelativeTime` doc-comment trimmed (stale polyfill claim).
- F7 — `<RecommendationCard>` 26-line slot-architecture header → 4-line
WHY.
- F9 — `pickProviderType` mis-anchored PROMPT_VERSION paragraph
removed.
- F10 — `legacy-payload.ts` `UnknownRec` interface inlined.
- F12 — `<ConfidenceMeter>` 20-line band-policy header → 3-line WHY.
(F1 + F8 are apply-no — both flagged for Marc's call. Logged to
v15-backlog.md "from-simplify" section.)
**0 reverts.** All landed cleanly with full test suite green per-step.
---
No reverts; all simplify changes shipped.
## Final verification
Run after all reconcile commits land. See "Final verification" status
block at the bottom of this document for actual numbers.
```
pnpm typecheck 0 errors
pnpm lint 0 errors / 12 baseline warnings
pnpm format:check All matched files use Prettier code style!
pnpm test --run 197 files / 1605 tests passing
pnpm test:integration 18 files / 66 tests passing
```
Cleanup note: removed orphan `src/components/settings/thresholds-
settings-section.tsx` (untracked leftover from B6's renaming refactor —
was blocking typecheck against the new layout).
Format sweep landed as a separate commit (`3048dd6`) — 72 files,
pre-existing drift accumulated across v1.4.x. No behaviour change.
Untracked dotted-segment route directories (`src/app/api/export/
{measurements,medications,mood}.csv/`, `full-backup.json/`) are
pre-existing v1.4.16 leftovers from B7's plain-segment pivot. Left in
place — not in scope for reconcile, but flagged for v1.4.17 cleanup.
## Pointers
---
- **v1.4.19 backlog**: `.planning/v1419-backlog.md` — security
HIGH-2 + 5 MEDs (code) + 4 MEDs (design) + 5 LOWs.
- **v1.5 backlog**: `.planning/v15-backlog.md` — updated with the
v1.4.18 Product-Lead review's C.12-C.16 (per-user UI prefs as a
platform pattern, achievement engine as next-best-action,
hidden pattern as feature-discovery, engagement loop, shell
layout audit).
- **Product-Lead strategic plan**:
`.planning/phase-D-v1418-product-lead-review.md` — the v1.5
backbone. Lift section C candidates + section D follow-ons +
section E watchlist into the v1.5 milestone planning doc.
## Pointer to v15-backlog.md
## Commits this session
Full deferred-item inventory at
`.planning/v15-backlog.md`. Sections:
- from-Product-Lead-strategic (C.1–C.11 + v1.6+ initiatives)
- from-design (deferred CRITICALs C1+C2 + HIGH/MED + 22 MED/LOW)
- from-code-review HIGH/MED
- from-security HIGH/MED
- from-senior-dev HIGH (architecture splits) / MED / LOW
- from-simplify (the 2 apply-no items)
- from-Wave-C deferred (Coolify image-digest, ARM matrix)
---
## Product-Lead review's standing
`.planning/phase-D-product-lead-review.md` IS the strategic v1.5
plan. No compaction needed. The reconcile report's Pointer section
defers to it as the source-of-truth for v1.5 milestone planning.
Marc-three-weeks-from-now reads it as-is to remember "what state is
the app in, and what does v1.5 actually need to look like".
- `545f44c fix(achievements): redact hidden-locked entries from API response`
- `720e6c8 refactor(v1.4.18): apply simplify-review safe suggestions`
- `fbf14fc fix(mood-chart): make chartKey opt-in and don't leak overlay state across pages`
- `194ec2f fix(chart-overlay-controls): bump cog to 44x44 tap target`
- `cf75579 fix(dashboard): wrap chart-overlay-prefs PUT in Serializable transaction`
- `c6e3ac6 fix(achievements): cap consistentMonthCount and clarify mood-improvement window`
- `3048dd6 style(prettier): sweep formatting across the tree`
+356 -144
View File
@@ -1,150 +1,362 @@
# v1.4.16 Phase-D — Security Review
# v1.4.18 phase-D security review
Reviewer: SECURITY
Scope: v1.4.15..HEAD diff (Wave-A + Wave-B, ~95 commits, ~14k LOC)
Constraint: NO commits, NO source edits, findings-only.
## Verdict
**0 CRITICAL** — no ship-blockers identified for v1.4.16.
**2 HIGH** — defense-in-depth gaps that should land in v1.4.16 patch tier or v1.4.17.
**5 MEDIUM**, **3 LOW**.
The v1.4.16 attack surface is small and well-defended:
- All new endpoints wrapped in `apiHandler()` (verified).
- All admin endpoints call `requireAdmin()` (verified).
- All user-scoped queries filter by `userId` from `requireAuth()` (verified).
- Provider-chain runner respects user isolation; cache is per-userId.
- AI keys remain encrypted with AES-256-GCM and never echoed plaintext.
- Confidence override is server-side; the model's claim is discarded.
- Recommendation feedback attribution is server-side; client cannot tamper.
- GDPR cascade-delete covers `RecommendationFeedback` (migration 0034 + cascade-delete integration test verified).
- JSON inspector renders via React `<pre>{JSON.stringify(...)}` — no `dangerouslySetInnerHTML`.
- `User.id` is cuid (alphanumeric), so `Content-Disposition` filenames carrying it are safe from path-injection.
- In-memory log buffer is bounded to 500 entries (FIFO); cannot grow unbounded under attack.
- Provider chain credential resolution refuses to ship a stale `aiBaseUrl` to OpenAI / Anthropic providers (the v1.4.x SSRF fix from `provider.ts` is preserved by the new `resolveProviderForType` switch).
---
## HIGH
### H1 — Wide-event `meta` field is not redacted before admin egress (B4)
- **Severity**: HIGH
- **File**: `src/app/api/admin/app-logs/route.ts:72-95` (`redactEventForEgress`); `src/lib/ai/provider-runner.ts:262-276` (`ai_chain_hop_*_reason` annotations)
- **Issue**: `redactEventForEgress()` redacts `error.message`, `error.stack`, `warnings[]`, `http.user_agent`, and `external_calls[*].error`, but **does not** redact the `meta` field (`Record<string, unknown>`) or `action.details`. The provider-runner annotates `meta.ai_chain_hop_<n>_reason` with the upstream error message capped at 240 chars (`provider-runner.ts:218-219` — `reason: status !== null ? \`HTTP ${status}: ${message}\` : message`). If an upstream provider (OpenAI, Anthropic, Codex) ever echoes the request `Authorization` header or an `sk-…` / `Bearer …` token in its error body — Anthropic in particular has historically returned the request payload in 4xx errors when JSON malformed — that token lands in `meta` raw and is rendered through the JSON inspector without redaction. The Loki shipper (`transports.emitEvent → appendLogEvent`) writes the raw event into the buffer, so `redactEventForEgress` is the only chance to scrub before the admin UI.
- **Attack scenario**: Attacker (or buggy upstream) provokes a 4xx that includes the bearer token in the response body → annotation captures it → admin (Marc) views `/admin/app-logs` → token visible in JSON inspector → token logged to admin-side telemetry / screenshot if the admin shares a screenshot. Since the in-memory ring is volatile, exposure window is bounded but real.
- **Recommendation**: Run `redactSecrets()` over JSON-stringified `meta` and `action.details` in `redactEventForEgress()` (deep-walk strings, or `JSON.parse(redactSecrets(JSON.stringify(meta)))`). Lowest-risk patch: append `out.meta = JSON.parse(redactSecrets(JSON.stringify(out.meta)))` and same for `action.details` if present. Alternatively, harden at write-time by redacting in `WideEventBuilder.annotate()` — but that's a wider blast radius.
- **Ship-blocker?**: No. Risk is upstream-dependent and the v1.4.16 admin surface is single-tenant (Marc). Should land in next patch (v1.4.17) before any multi-admin scenario.
### H2 — `/api/admin/audit-log` returns raw `details` JSON to admins without redaction (B4)
- **Severity**: HIGH
- **File**: `src/app/api/admin/audit-log/route.ts:127-149` (no `redactSecrets`); `src/components/admin/login-overview-section.tsx:148` (renders `entry.details` raw into the CSV export and React tree)
- **Issue**: The audit-log GET returns `details` (a JSON string column) verbatim to admins. Some legacy audit rows can contain identifiers in `details` (e.g. `auth.login.failed → details: { identifier: <user-supplied login> }` at `src/app/api/auth/login/route.ts:63`). User-supplied `identifier` values are bounded but not filtered for token-shaped strings. If a user pastes `Bearer hlk_...` into the username field of a failed login (typo or recon), the next admin viewing `/admin/login-overview` sees the raw string in the JSON cell and the CSV export. This was acceptable when audit-log was admin-only ad-hoc; B4 productionised the surface (filter, paginate, CSV export), so the egress path is now warm.
- **Recommendation**: Wrap `entry.details` in `redactSecrets()` at the API layer (parse JSON → walk strings → re-serialise) before returning. Same redaction belongs in the CSV export path on the client. Cheaper alternative: have `auditLog()` (in `src/lib/auth/audit.ts:16`) call `redactSecrets()` on every string-shaped value of `details` at write-time so the row never persists a token.
- **Ship-blocker?**: No. Same reasoning as H1 — single-tenant for v1.4.16. Should ship in v1.4.17.
---
## MEDIUM
### M1 — `POST /api/insights/feedback` has no rate limit; aggregator pollution feasible
- **Severity**: MEDIUM
- **File**: `src/app/api/insights/feedback/route.ts:30-103`
- **Issue**: The feedback endpoint requires auth + idempotency + Zod validation, but has no `checkRateLimit()` call. The unique key `(userId, recommendationId, recommendationText)` prevents replay-attack-style spam, but a determined user can submit thousands of distinct feedback rows by varying `recommendationId` (≤200 chars) and `recommendationText` (≤2000 chars). Each row is server-attributed to that user's `providerType` + `promptVersion`, so the attack distorts that user's slice in the aggregator — but cross-user buckets are also distorted because the aggregator buckets on `(severity, metricSourceType, providerType, promptVersion)` not on `userId`. A single user spamming "thumbs-down + providerType=codex" can shift the admin AI quality view's helpful-rate for the entire codex+severity slice.
- **Recommendation**: Add `checkRateLimit("insights-feedback:" + user.id, 60, 60 * 60 * 1000)` (60 / hour seems generous — a user rates one rec per minute at most). Also consider: per-user weighting in the aggregator so a single user cannot dominate a bucket.
- **Ship-blocker?**: No. Marc is single-tenant, so adversarial feedback is N/A in production.
### M2 — `metricSourceType` is client-supplied free-form text (B5e)
- **Severity**: MEDIUM
- **File**: `src/lib/validations/recommendation-feedback.ts:46` (Zod allows `z.string().min(1).max(80)`); `src/lib/jobs/feedback-aggregator.ts:70-92` (used as bucket key)
- **Issue**: The Zod schema accepts any 1-80 char string for `metricSourceType`. There's no allow-list. A client can supply `metricSourceType: "../etc/passwd"` or `"<script>alert(1)</script>"` or any string. Two consequences:
1. **Bucket-keyspace pollution**: each unique value spawns a fresh bucket, so a malicious client can balloon `AppSettings.adminAiInsightsFeedbackSummary` JSON unboundedly (limited only by 30-day rolling window × 80-char keys).
2. **No XSS** (admin UI renders via React text node — verified `ai-quality-section.tsx:180`), but the value lands in the audit log `details` blob which then flows into B4's audit viewer.
- **Recommendation**: Pin `metricSourceType` to the same allow-list the AI schema uses (`aiRecommendationSchema.metricSource.type` enum). Same closed enum is used everywhere else in the AI pipeline; only the feedback API accepts wildcard.
- **Ship-blocker?**: No.
### M3 — `audit-log` filter `target` substring on raw `details` field is broad
- **Severity**: MEDIUM
- **File**: `src/app/api/admin/audit-log/route.ts:111-115` (`details: { contains: parsed.target }`)
- **Issue**: The `target` query parameter does a Prisma `contains` substring match on the raw JSON-encoded `details` column. Prisma parameterizes the query (no SQL injection), but the substring match is **case-sensitive** on a JSON-encoded blob and very expensive on a large audit table (full table scan). An admin with `target=a` filter triggers a full-table scan + decoded substring on every row. With 10k+ audit rows + the new B4 paginated UI sweeping pages, the DB becomes the bottleneck. This is a DoS-via-admin not a security boundary breach.
- **Recommendation**: Either (a) require `target` to be exact-match, (b) add a GIN/trigram index on `details` if substring is needed, or (c) bound `target` to ≥3 chars.
- **Ship-blocker?**: No (admin-only).
### M4 — `User.aiProviderChain` JSON column allows tampering via direct DB edit but parser is defensive
- **Severity**: MEDIUM (informational)
- **File**: `src/lib/ai/provider-chain.ts:78-120` (`parseProviderChain`)
- **Issue**: The chain is stored as JSON, parsed defensively at every read with the malformed-row falls-back-to-default contract. The parser correctly: (a) deduplicates, (b) rejects unknown provider types, (c) normalises priority. However, the parser does NOT enforce that priority numbers are unique — a chain like `[{p:1, openai}, {p:1, codex}]` collapses by sort-stability into `[openai, codex]`. The `PUT` endpoint normalises priority to insertion order (route.ts:106-110) so this is not exploitable via the wire, but a direct DB edit could bypass. Defense-in-depth: `parseProviderChain()` normalises priority itself rather than trusting the persisted value.
- **Recommendation**: Mirror the route's normalisation in `parseProviderChain` (`return validSorted.map((e, idx) => ({ ...e, priority: idx + 1 }))`).
- **Ship-blocker?**: No (no exposure since the only writer is the `PUT` endpoint we control).
### M5 — `GET /api/user/ai-provider` decrypts the API key in memory just to expose last-4
- **Severity**: MEDIUM (informational; pre-existing — not v1.4.16, but relevant given B2 expanded UI surface)
- **File**: `src/app/api/user/ai-provider/route.ts:34-41` (`decrypt(...).slice(-4)` for OpenAI + Anthropic)
- **Issue**: Every GET decrypts the full encrypted apiKey to extract the last 4 chars. The plaintext key briefly lives in the V8 string heap (~ms). If the response is logged with `responseBody` capture (per the idempotency replay-cache), there's no key in the response, but the in-process buffer / Glitchtip path could pick up a partial. Also, repeated decryption widens the window for a side-channel timing attack against AES-GCM (negligible in practice, but defense-in-depth).
- **Recommendation**: Store a 4-char preview alongside the encrypted key on write (`aiOpenaiKeyPreview` column, NOT encrypted). Decrypt only when actually building a provider. Same applies to Anthropic.
- **Ship-blocker?**: No. Pre-existing.
---
## LOW
### L1 — Recommendation text stored alongside helpful/notHelpful verdict in DB (per-user)
- **Severity**: LOW
- **File**: `src/app/api/insights/feedback/route.ts:64-75`; `prisma/migrations/0034_recommendation_feedback/migration.sql`
- **Issue**: The audit details record `{recommendationId, severity, helpful, providerType, promptVersion}` — does NOT include `recommendationText` (good). However, `recommendationText` IS persisted to the `RecommendationFeedback` table itself (the dedup partner). An admin reading `recommendation_feedback` directly sees the user's full rec text alongside their helpful/notHelpful verdict. This is by design (Marc's research §3 single-user-default-on policy), but operators on a shared deployment should know.
- **Recommendation**: Document the exposure in `docs/audit/v1416-summary.md` (privacy section) so future deployments understand the per-user-rec storage trade-off.
- **Ship-blocker?**: No.
### L2 — Host-metrics endpoint returns ~1440 rows for `since=24h` with no streaming
- **Severity**: LOW
- **File**: `src/app/api/admin/host-metrics/route.ts:32-56`
- **Issue**: `since=24h` returns ~1440 rows (one per minute) in one JSON response. The route loads all into memory with the BPS computation. At 7d retention an admin could enumerate the full table by dropping the `since` filter — but the route caps to 24h via the enum, so the maximum response is bounded. Not a real DoS.
- **Recommendation**: None required.
- **Ship-blocker?**: No.
### L3 — `redactSecrets` regex does not cover "Authorization: Basic <base64>" or generic JWT shape
- **Severity**: LOW (pre-existing)
- **File**: `src/lib/logging/redact.ts:25-49`
- **Issue**: The redaction rules cover `Bearer <token>`, `bot<n>:<token>`, `sk-…`, `hl[kr]_…`, and query-string secrets. They do NOT cover Basic auth headers or JWT (`eyJ...`) tokens. If an upstream provider ever echoes a JWT in an error message, redaction misses.
- **Recommendation**: Add `Basic\s+\S+` and `\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b` patterns. Risk of over-matching is low since both shapes are token-specific.
- **Ship-blocker?**: No.
---
## Items checked, no finding
- **`POST /api/insights/feedback` auth, idempotency, schema** — all enforced (`apiHandler` + `requireAuth` + `withIdempotency` + Zod). PII: rec text is not in audit details.
- **`GET /api/admin/host-metrics` admin gate** — `requireAdmin()` enforced. No host-info leak: the response is admin-only, no Bearer surface, the `dynamic = "force-dynamic"` guard prevents Edge cache surprise.
- **`GET /api/admin/app-logs` admin gate + redactSecrets** — admin-gated; `redactSecrets()` applied to error.message, error.stack, warnings, user_agent, external_calls[*].error. (See H1 for the gap on `meta`.)
- **`GET /api/admin/audit-log` admin gate + filter pagination** — admin-gated; Zod validates `actor`/`action`/`target`/`since`/`until`/`page`/`perPage`; Prisma parameterizes. No SQL injection.
- **Export endpoints (`/api/export/*`)** — all wrapped in `apiHandler` + `requireAuth`; queries scoped `where: { userId: user.id }`; rate-limit `export:<userId>` 10/h shared bucket; `Content-Disposition` filenames embed cuid (alphanumeric, safe); audit-log entries per export type.
- **Provider chain credential safety** — `resolveProviderForType()` (provider.ts:325) refuses to forward a stale `aiBaseUrl` to OpenAI/Anthropic; LOCAL is the only path that uses `aiBaseUrl`, and the LOCAL path goes through `isPublicUrl()` SSRF guard. AES-256-GCM encryption preserved; no plaintext key in any response.
- **Confidence override safety** — `applyConfidenceOverride()` (generate-insight.ts:214-240) discards `rec.confidence` from the model and replaces with `computeConfidence()`. Wide-event annotation captures the model's value separately for admin observability.
- **Feedback attribution** — `resolveFeedbackAttribution()` (feedback-attribution.ts:70-86) reads the user's most recent `insights.generate` audit row; `pickProviderType()` priority is `chainProviderType > providerType > "unknown"`. Client cannot supply `providerType` / `promptVersion` (Zod schema omits both).
- **GDPR cascade-delete** — `RecommendationFeedback` migration 0034 has `ON DELETE CASCADE` on `user_id` FK; integration test `tests/integration/cascade-delete.test.ts:102+188` verifies the row is gone after user deletion.
- **Comparison snapshot scope** — `buildComparisonSnapshotForUser()` (generate/route.ts:86-150) reads `where: { userId, type }` per metric. No cross-user data path exists.
- **AI section masking** — `<PasswordInput>` used for all three apiKey fields (ai-section.tsx:810, 996, 1158). Last-4 preview only (`...wxyz` shape).
- **Connect/disconnect routes** — `PATCH /api/user/ai-provider` is `requireAuth`-gated. The new `PUT /api/insights/provider-chain` is `requireAuth`-gated, dedupes provider entries, and normalises priority.
- **Provider selection persists per-user** — `User.aiProviderChain` is per-user; no cross-tenant query path.
- **Host-metrics retention cleanup** — `runHostMetricTick` deletes only from `prisma.hostMetric` (`host-metric-sampler.ts:163`); cannot touch unrelated tables.
- **In-memory log buffer growth** — `LOG_BUFFER_MAX = 500` hard cap, FIFO eviction (`in-memory-buffer.ts:24-37`). Per-process. No exposure to attacker-controlled growth (the sampler gates on level).
- **JSON inspector modal XSS** — `<pre>{JSON.stringify(selected, null, 2)}</pre>` (app-log-preview-section.tsx:282-284). Renders via React text node, no `dangerouslySetInnerHTML`.
- **i18n strings via `dangerouslySetInnerHTML`** — no occurrences across v1.4.16 surface (only `src/app/layout.tsx:93` for the theme-init script — pre-existing, unrelated).
- **`apiHandler()` wrap** — verified for `/api/insights/feedback`, `/api/insights/provider-chain` (GET+PUT), `/api/admin/host-metrics`, `/api/admin/app-logs`, `/api/admin/audit-log`, `/api/admin/audit-log/actions`, `/api/admin/ai-quality`, `/api/export/measurements`, `/api/export/medications`, `/api/export/mood`, `/api/export/full-backup`. All present.
- **New routes log auth context via apiHandler** — confirmed via `requireAuth()` / `requireAdmin()` annotation chain.
Reviewer: SECURITY (5 reviewers in parallel)
Range: `v1.4.17..HEAD` (HEAD = 3d58557, origin/main)
Reviewed: 2026-05-09 / 2026-05-10
Scope per request: B1 hidden achievements, A3 chart-overlay-prefs,
A2 admin-shell `no-scrollbar`, A1 BD-tile sub-values, plus general
hygiene (logging redaction, apiHandler, dangerouslySetInnerHTML).
---
## Summary
The v1.4.16 release ships clean. No CRITICAL findings; the two HIGHs (H1 + H2) are defense-in-depth gaps that matter only at multi-admin scale, which HealthLog has not yet reached. Recommend tracking H1 + H2 + M1 + M2 as a v1.4.17 hardening bucket; M3-M5 + L1-L3 are nice-to-have.
| Severity | Count |
| -------: | ----: |
| CRITICAL | 0 |
| HIGH | 2 |
| MEDIUM | 1 |
| LOW | 2 |
Ship-blockers: **0**. The two HIGH findings are about
hidden-achievement secrecy. The DOM is held: a casual user opening
inspect-element sees the opaque "Hidden achievement" placeholder
with no predicate text. The leak surfaces are (a) the JSON API
response and (b) the client-side i18n bundle that already ships
the strings — neither is a v1.4.18-specific regression in the
sense that fixing them requires architectural moves; both are
ship-OK as documented backlog items.
A1 / A2 / A3 are all clean from a security standpoint:
- A1's `computeBpInTargetWindows()` is pure; the `/api/analytics`
caller scopes every `findMany` with `userId: user.id`.
- A2's `no-scrollbar` is a class-scoped CSS rule on
`<nav class="no-scrollbar">` only — _not_ a global `*` selector
— so data tables that genuinely overflow continue to paint their
scrollbars normally.
- A3's `PUT /api/dashboard/chart-overlay-prefs` is `requireAuth`-
gated, scopes the read+write to `user.id`, and the Zod schema
rejects unknown chart keys / non-boolean toggle values.
---
## CRITICAL
(none)
---
## HIGH
### HIGH-1 — Hidden-achievement predicate leaks via /api/gamification/achievements JSON response
**Severity**: HIGH (matches Marc's explicit "must NOT leak in network response" criterion)
**File**: `/Users/marc/Projects/HealthLog/src/app/api/gamification/achievements/route.ts:838-854` and `:856-873`
**Ship-blocker**: NO — DOM-level secrecy holds and the i18n bundle already exposes the same data, so the network leak is not a v1.4.18-only regression. Recommend fix in v1.4.19.
**Issue**
The web response payload `{ summary, achievements, metrics }` ships
the FULL achievement object for every hidden Easter-egg even when
the user has not unlocked it. For a locked
`hidden-night-owl` the response body contains:
```json
{
"id": "hidden-night-owl",
"metric": "nightOwlCount",
"category": "hidden",
"titleKey": "achievements.badges.hiddenNightOwl.title",
"descriptionKey": "achievements.badges.hiddenNightOwl.description",
"icon": "Moon",
"format": "count",
"target": 1,
"current": 0,
"points": 25,
"unlocked": false,
"progressPercent": 0,
"completedAt": null,
"isHidden": true
}
```
The `id`, `metric`, `titleKey`, `descriptionKey` and `icon` fields
each individually disclose the trigger (night owl, between 02:00
and 04:00, Moon icon). The `metrics` block at the bottom of the
response (`route.ts:853`) compounds the leak by including
`nightOwlCount`, `earlyBirdCount`, `leapDayCount`, `doctorPdfCount`,
`localeFlipCount` as plain numeric counters — disclosing both the
metric names AND the user's current progress toward them.
The iOS branch (`route.ts:856-872`, `?format=ios`) is **strictly
worse**: it server-side-resolves the i18n keys and ships plain text
`title: "Night owl"` and `description: "Logged an entry between
02:00 and 04:00 in the morning."` for locked hidden achievements.
The iOS client gets the secret unconditionally.
This contradicts Marc's stated requirement
("the user must not be able to view-source or inspect-element to
discover what the hidden conditions are") for any user who opens
DevTools → Network and reads the response body.
**Mitigation already in place (partial)**
DOM rendering is gated correctly by
`/Users/marc/Projects/HealthLog/src/app/achievements/page.tsx:146-174`
(the `isHidden && !unlocked` branch renders the opaque placeholder
without referencing real fields). The unit suite at
`/Users/marc/Projects/HealthLog/src/app/achievements/__tests__/page.test.tsx:252-262`
locks this in. Casual inspect-element does NOT break the secret —
only the network tab and the JS bundle do.
**Recommendation**
In `route.ts` between `applyDiscoveryFilter` and the final
`apiSuccess`, project hidden+locked rows down to a redacted shape
before serialization:
```ts
const SAFE_HIDDEN_LOCKED = (a: AchievementProgress) => ({
id: "hidden-locked-placeholder", // not the real id
category: "hidden" as const,
isHidden: true,
unlocked: false,
// every other field absent OR a constant placeholder
});
const projected = visibleAchievements.map((a) =>
a.isHidden && !a.unlocked ? SAFE_HIDDEN_LOCKED(a) : a,
);
```
Also strip the hidden-only metric counters from the `metrics`
block when none of the hidden achievements they back are unlocked
(or just drop the entire `metrics` object from the response — the
client only consumes it for `metricPercent` formatting, which the
hidden-counter metrics never use).
For `?format=ios`, do the same projection BEFORE the
`t.t(a.titleKey)` calls so the iOS shape never resolves the secret
strings.
Add a test that the network response for a fresh user with zero
unlocks contains _zero_ occurrences of the substrings
`nightOwl`, `earlyBird`, `leapDay`, `doctorPdf`, `localeFlip`,
`Moon`, `Sun`, `FileText`, `Languages` (icon names), and zero
occurrences of the i18n keys `hiddenNightOwl`, `hiddenEarlyBird`,
etc.
---
### HIGH-2 — i18n message bundle ships hidden-achievement strings to every client
**Severity**: HIGH
**File**: `/Users/marc/Projects/HealthLog/src/lib/i18n/context.tsx:15-16` (pre-existing client-side bundle import); content at `/Users/marc/Projects/HealthLog/messages/en.json:2195-2218` and `/Users/marc/Projects/HealthLog/messages/de.json` parallel.
**Ship-blocker**: NO — pre-existing v1.4.x architectural decision; v1.4.18 is the first release where this matters because v1.4.18 added the hidden Easter-eggs.
**Issue**
`src/lib/i18n/context.tsx` is a `"use client"` module that imports
`messages/en.json` and `messages/de.json` at module-eval time:
```ts
import deMessages from "../../../messages/de.json";
import enMessages from "../../../messages/en.json";
```
That means the FULL i18n bundle (every key, every locale) ships
inside the client JS bundle for every page in the app. The strings
`"Logged an entry between 02:00 and 04:00 in the morning."`,
`"Logged an entry on February 29."`,
`"Switched the app language at least once."`, etc. live in the
bundle that any logged-in user can `view-source` on the
`_next/static/chunks/*.js` URL.
Even if HIGH-1 is fixed and the API never speaks the hidden
predicates, a user who searches the JS bundle for the string
`achievements.badges.hidden` finds them all. So HIGH-1 is a partial
mitigation; HIGH-2 is the real long-term fix.
**Recommendation**
Move hidden-achievement strings out of the client-side i18n bundle.
Two viable approaches:
1. **Server-side resolution for unlocks only.** Strip
`messages/*.json` keys matching `^achievements.badges.hidden` at
build time using a Next.js build hook; ship a separate
`messages-hidden.json` only fetched server-side by the
achievements API when an unlock is detected. The unlock toast
would do a one-shot fetch to retrieve the title/description.
2. **Encode the hidden strings.** Less robust but cheaper:
replace the hidden-achievement strings with reversible
obfuscation (rot13, base64, etc.) in `messages/*.json`, decode
client-side ONLY when `unlocked === true`. A determined
attacker still beats this in 30 seconds, but it stops the
accidental view-source leak.
Approach 1 is the right v1.5 fix. Approach 2 is a v1.4.19 stopgap.
Either way, this is NOT a ship-blocker for v1.4.18 because the
existing 5+ "achievements.badges.hidden\*" keys were never going to
be a v1.4-class secret — Marc accepted "playful, off-the-wall, NOT
health-coercive" semantics, and HIGH-2's mitigation cost is
disproportionate to a casual Easter-egg game.
---
## MEDIUM
### MED-1 — Read-modify-write race in `/api/dashboard/chart-overlay-prefs` PUT
**Severity**: MEDIUM (correctness > security; data integrity edge case under concurrent writes)
**File**: `/Users/marc/Projects/HealthLog/src/app/api/dashboard/chart-overlay-prefs/route.ts:56-76`
**Ship-blocker**: NO — single-tab single-user pattern is the realistic case; the v1.4.18 release ships at most 5 chart keys, so the worst-case data loss is 1-of-5 toggles.
**Issue**
The handler reads `dashboardWidgetsJson`, merges the new chart's
prefs locally in the route handler, then writes the whole blob
back. Two concurrent PUTs (e.g. user opens two tabs and toggles
overlays in both) will race: the second `findUnique` may see the
first PUT's write or may see the prior state, and the second
`update` always clobbers the first PUT's `chartOverlayPrefs` key
for any chartKey other than its own. The same race applies to a
PUT on `/api/dashboard/widgets` overlapping with a PUT on
`/api/dashboard/chart-overlay-prefs` — they both serialize the
whole blob.
Not a security issue — the user can only race against themselves;
no cross-user write is possible because `requireAuth()` gates and
the `where: { id: user.id }` clauses are correct. It is a data-
integrity concern.
**Recommendation**
Wrap the read+write in a single Prisma transaction with
`SERIALIZABLE` isolation, OR move the chart overlay prefs to a
dedicated column (`User.chartOverlayPrefsJson`) so the two PUT
routes don't share a write target. The latter also lets the JSON
column receive a Postgres `||` jsonb-merge update that is
naturally atomic.
This is a v1.5 follow-up — defer.
---
## LOW
### LOW-1 — Add regression test pinning `!a.isHidden` filter on `nextAchievement` selection
**Severity**: LOW
**File**: `/Users/marc/Projects/HealthLog/src/app/api/gamification/achievements/route.ts:833-836` and `/Users/marc/Projects/HealthLog/src/app/achievements/page.tsx:351-389`
**Ship-blocker**: NO — the route correctly filters `!a.isHidden`.
**Note**
Reviewed. The `nextAchievement` selection in route.ts is
```ts
visibleAchievements
.filter((a) => !a.unlocked && !a.isHidden)
.sort(...)
```
so hidden-locked achievements never become the "next goal" card on
the achievements page — confirmed safe.
The `fullResult.summary.nextAchievement` computed earlier in
`/Users/marc/Projects/HealthLog/src/lib/gamification/achievements.ts:747-750`
does NOT filter on `isHidden`, but that summary is discarded by
the route (which recomputes its own summary at lines 833-851).
No leak path today.
Still, please add a regression test pinning the `!a.isHidden`
filter in the route so a future refactor doesn't drop it. Suggested
location: `/Users/marc/Projects/HealthLog/src/app/api/gamification/achievements/__tests__/`.
---
### LOW-2 — `data-category="hidden"` on the section element advertises that hidden achievements exist
**Severity**: LOW (cosmetic / informational)
**File**: `/Users/marc/Projects/HealthLog/src/app/achievements/page.tsx:411-413`
**Ship-blocker**: NO
**Note**
The page renders
```html
<section
data-category="hidden"
data-slot="achievements-category"
aria-labelledby="achievements-category-hidden"
></section>
```
This is **intentional** per Marc's spec ("Hidden achievements
appear in the Achievements tab as 'Hidden' cards (user knows they
exist but not what they are)"). Calling it out only so the next
reviewer doesn't flag it. The DOM markup correctly exposes the
existence of the hidden category without exposing the predicates.
The `aria-label="Hidden locked achievement"` (page.tsx:150) is
the same — by design, accessibility-correct.
---
## Cross-cutting hygiene checks (all clean)
- **dangerouslySetInnerHTML / innerHTML / eval / new Function**: zero
new occurrences in the v1.4.17..HEAD diff.
- **apiHandler wrapping**: every new route uses `apiHandler(async
...)` per CLAUDE.md
(`/Users/marc/Projects/HealthLog/src/app/api/dashboard/chart-overlay-prefs/route.ts:41`).
- **requireAuth gating**: every new route calls
`await requireAuth()` before touching `prisma`.
- **userId scoping**: every new `prisma.*.findMany` /
`findUnique` / `update` filters by `userId: user.id` or
`id: user.id` — confirmed for the analytics route's BP-window
reads (`src/app/api/analytics/route.ts:67-83`), the achievements
reads (`src/app/api/gamification/achievements/route.ts:474-561`),
and the chart-overlay-prefs read+write
(`src/app/api/dashboard/chart-overlay-prefs/route.ts:56-71`).
- **Zod validation on PUT bodies**: yes; `prefsSchema` rejects
unknown chart keys and non-boolean toggles via `z.enum` +
`z.boolean()`.
- **redactSecrets coverage**: the new `annotate()` calls only
ship plain identifiers (`chart_key`, `flags_on`, `format`,
`visible_count`); no PII or token material reaches the log
pipeline. The existing `apiHandler` middleware redacts error
messages via `redactSecrets()` — confirmed in
`src/lib/logging/event-builder.ts:65,72`.
- **No SQL injection vector**: all DB calls use Prisma with
parameterized queries.
- **No new external HTTP calls**: the achievement evaluator is
pure and in-process; chart-overlay-prefs only touches the
database.
- **Audit trail for new actions**: the new
`settings.locale.update` audit row in
`/Users/marc/Projects/HealthLog/src/lib/auth/profile-update.ts`
correctly suppresses the log when the locale value didn't
change (commit `75c74f1`), preventing audit-log noise that
would otherwise let a user grind the polyglot Easter-egg by
saving the same locale repeatedly.
- **Cross-user write protection on `/api/dashboard/chart-overlay-prefs`
PUT**: Zod-rejected unknown shape; `where: { id: user.id }`
on the read AND the write; no userId field accepted from the
request body. Cannot be coerced into writing another user's
layout.
---
## Verdict
Ship v1.4.18 as-is. File HIGH-1 + HIGH-2 + MED-1 + LOW-1 to the
v1.4.19 / v1.5 backlog. The hidden Easter-egg discovery promise
to Marc holds at the DOM layer (which is what most users will
ever inspect); the network/bundle leak is a known consequence of
the i18n architecture and not a fresh v1.4.18 regression severe
enough to gate the release.
+330 -585
View File
@@ -1,619 +1,364 @@
# Phase D — Senior-dev review (v1.4.16)
# Phase D — Senior-Dev Architectural Review (v1.4.18)
Lens: structure, file-size discipline, naming, separation of concerns,
layering coherence, test architecture, premature abstraction. Distinct
from code-review (correctness/bugs), security, design, simplify.
Lens: structure, file size, naming, separation of concerns, layering. NOT
correctness or business logic — those are the code-reviewer's lane.
Scope: 167 changed files in `git diff v1.4.15...HEAD` (+20,357 / −2,031
lines). Focus on the largest deltas: AI module (`src/lib/ai/` grew with
B5a refs, B5b chain runner, B5c rationale, B5d confidence, B5e feedback),
chart wrappers (`health-chart.tsx` +729, `mood-chart.tsx` +542), the
recommendations surface (`<RecommendationCard>` 417 new lines,
`<RecommendationsGrid>`, `<RecommendationFeedback>`, `<ConfidenceMeter>`),
admin extensions (host-metrics, app-logs, audit-log filters, ai-quality),
and B7's settings-export consolidation.
## Scope
Verdict at a glance: **0 CRITICAL, 3 HIGH, 9 MED/LOW**. The marathon
hit its file-size target overall — only one v1.4.16-touched file is
visibly over the comfortable ceiling and the named-slot pattern in
`<RecommendationCard>` kept it under 420 lines despite five buckets
plugging into it. Worktree adoption (last milestone's H3) worked well:
the cross-agent staging races mostly fall into "wrong commit subject"
territory rather than lost code. The findings below are real maintenance
debt; none of them block the v1.4.16 ship.
Reviewed against `git diff --name-only v1.4.17...agent/b1-achievements` (the
worktree branch carrying the full v1.4.18 implementation: A1 + A2 already on
`main`, A3 charts revert + B1 achievements expansion sitting on the agent
branches awaiting reconcile). 59 files / +4961 -1612 LOC.
---
Files inspected closest:
## CRITICAL — none
- `src/lib/analytics/bp-in-target.ts` (A1 windowed helper)
- `src/app/api/analytics/route.ts` (A1 wiring)
- `src/app/page.tsx` (A1 tile wiring)
- `src/app/globals.css` + admin/settings shells (A2 no-scrollbar)
- `src/components/charts/health-chart.tsx` / `mood-chart.tsx` /
`medication-compliance-chart.tsx` (A3 revert + overlay-controls)
- `src/components/charts/chart-overlay-controls.tsx` + `use-chart-overlay-prefs.ts`
- `src/lib/dashboard-layout.ts` (overlay prefs persistence)
- `src/app/api/dashboard/chart-overlay-prefs/route.ts` (overlay PUT)
- `src/lib/gamification/achievements.ts` (B1 definitions + evaluators)
- `src/lib/gamification/expansion-metrics.ts` (B1 expansion engine)
- `src/app/api/gamification/achievements/route.ts` (B1 route stitching)
- `src/app/achievements/page.tsx` + `src/components/gamification/*`
- All companion unit + integration + e2e tests
(No architectural mistakes need fixing before v1.4.16 ships.)
Lines-of-code deltas v1.4.17 → v1.4.18:
---
| File | v1.4.17 | v1.4.18 | Δ |
| ------------------------------------------------------- | ------: | ------: | ---: |
| `src/components/charts/health-chart.tsx` | 1431 | 1339 | -92 |
| `src/components/charts/mood-chart.tsx` | 958 | 850 | -108 |
| `src/components/charts/medication-compliance-chart.tsx` | 466 | 444 | -22 |
| `src/lib/gamification/achievements.ts` | 431 | 839 | +408 |
| `src/lib/gamification/expansion-metrics.ts` | ─ | 374 | +374 |
| `src/app/api/gamification/achievements/route.ts` | 776 | 876 | +100 |
| `src/app/achievements/page.tsx` | 388 | 441 | +53 |
| `src/lib/dashboard-layout.ts` | 200 | 287 | +87 |
| `src/lib/analytics/bp-in-target.ts` | 178 | 230 | +52 |
## HIGH
Charts shrunk after the revert (good — gradient primitive deletion +
emoji-glyph removal more than offset the new overlay-control mounts).
Gamification grew on the data side (definitions doubled from ~14 to
38 badges) but the new code lives in two coherent files (`achievements.ts`
### H1 — `src/lib/ai/` directory has 19 flat files (4,726 lines) with five orthogonal responsibility groups; the named-slot success of `<RecommendationCard>` shows the AI module deserves the same sub-directory split
- `expansion-metrics.ts`) with a clear definition / evaluator split.
- **Files / pattern**: `/Users/marc/Projects/HealthLog/src/lib/ai/` —
`provider.ts` (515), `codex-client.ts` (484), `provider-runner.ts`
(371), `codex-oauth.ts` (366), `schema.ts` (329), `generate-insight.ts`
(315), `medical-references.ts` (185), `provider-chain.ts` (137),
`types.ts` (132), `confidence.ts` (102), `citation-coverage.ts` (94),
`local-client.ts` (92), `openai-client.ts` (88), `feedback-attribution.ts`
(86), `legacy-payload.ts` (55), plus `prompts/` already split out.
- **Issue**: Five distinct concerns sit at the same flat depth, all
importing each other:
- **Provider clients**: `codex-client.ts`, `codex-oauth.ts`,
`codex-slug-cache.ts`, `openai-client.ts`, `anthropic-client.ts`,
`local-client.ts`, `mock-client.ts` (7 files).
- **Provider resolution / chain runtime**: `provider.ts`,
`provider-chain.ts`, `provider-runner.ts` (3 files; `provider.ts`
AND `provider-runner.ts` both export 4 different
"resolve/run" entry points that route layers must navigate).
- **Schema / validation / types**: `schema.ts`, `types.ts` —
two response shapes still co-exist (last milestone's M6 — see
M5 below for the v1.4.16 update).
- **Domain logic**: `confidence.ts`, `citation-coverage.ts`,
`legacy-payload.ts`, `feedback-attribution.ts`,
`medical-references.ts`, `generate-insight.ts`.
- **Prompts**: `prompts/` already a sub-directory (the only one).
Today an AI-feature add lands one file in the flat `ai/` and one
in `ai/prompts/`. Tomorrow's "B6: prompt feedback ratchet" or
"B7: tool-call provider" will keep stuffing the flat directory.
- **Recommendation**: Re-shape to:
```
src/lib/ai/
providers/ ← codex-*, openai-*, anthropic-*, local-*, mock-*
runtime/ ← provider.ts (resolve), provider-chain.ts,
provider-runner.ts, generate-insight.ts
schema/ ← schema.ts, types.ts (after merge — see M5)
domain/ ← confidence.ts, citation-coverage.ts,
legacy-payload.ts, feedback-attribution.ts,
medical-references.ts
prompts/ ← unchanged
```
This is a mechanical rename + import-rewrite pass that the test
suite will catch in seconds. The named-slot pattern in
`<RecommendationCard>` proved the codebase responds well to
thoughtful structural splits; the AI module needs the same
treatment before v1.4.17 / v1.5 add another bucket.
- **Ship-blocker?**: no. v1.4.17 housekeeping.
## Findings
### H2 — `src/components/insights/insight-advisor-card.tsx` (690 lines) has become a god-component carrying 4 inline sub-components, 3 lookup tables, the legacy-payload CTA, the data-quality drawer, and the recommendations grid wrapping
### F1 — `ChartOverlayPrefs` type duplicated under two names
- **File**: `/Users/marc/Projects/HealthLog/src/components/insights/insight-advisor-card.tsx`
(690 lines, was 582 in v1.4.15 — +108 from B5c legacy-payload CTA
+ B1b summary mini-charts + recommendations-grid wrapping)
- **Issue**: Single client module hosts:
- `<HeroFinding>` (~60 lines, severity-tinted top-finding card)
- `<InlineCharts>` (~50 lines, chart-token expander)
- `<SectionSeparator>` (~15 lines, plus a duplicated comment header
"Section Separator" appearing twice in the file at lines 284 and
286 — copy-paste residue)
- `<AssessmentIcon>` (icon switcher)
- 4 module-scope lookup tables (`CLASSIFICATION_STYLES`,
`HERO_STYLES`, `CONFIDENCE_STYLES`, `INLINE_CHART_TITLE_KEYS`,
`CONFIDENCE_LABEL_KEYS`)
- The main `<InsightAdvisorCard>` with loading / empty / error /
full branches plus the legacy-payload CTA banner plus the
data-quality collapsible drawer
When the next phase (e.g. B5f or v1.4.17 comparison-callout)
needs to add a UI block, the only natural insertion point is more
inline JSX inside this file. The component already has 5 separate
concerns; the named-slot discipline that worked for
`<RecommendationCard>` was NOT applied here.
- **Recommendation**: Extract:
- `src/components/insights/_advisor/hero-finding.tsx` — `<HeroFinding>` + `HERO_STYLES` + `<AssessmentIcon>`
- `src/components/insights/_advisor/inline-charts.tsx` — `<InlineCharts>` + the title-keys map
- `src/components/insights/_advisor/data-quality-drawer.tsx` — collapsible block
- `src/components/insights/_advisor/legacy-payload-cta.tsx` — banner
- `<InsightAdvisorCard>` becomes a slim composer (~150 lines) that
just orchestrates loading/empty/error and delegates to the
extracted blocks. Mirror the `<AdminShell>` per-section split
that v1.4.15 phase B4 already established as the codebase
pattern.
Remove the duplicate `// ─── Section Separator ───` comment block.
- **Ship-blocker?**: no. v1.4.17 housekeeping.
- **Severity:** MED
- **File:** `src/components/charts/chart-overlay-controls.tsx` (line 49,
exports `ChartOverlayPrefsValue` + `DEFAULT_CHART_OVERLAY_PREFS`) vs
`src/lib/dashboard-layout.ts` (line 121, exports `ChartOverlayPrefs` +
`DEFAULT_CHART_OVERLAY_PREFS`)
- **Issue:** Two parallel exports with **identical** shape (3 booleans
named `showTrendIndicator` / `showTrendArrow` / `showTargetRange`) live
in two files under two type names. Both also re-export a
`DEFAULT_CHART_OVERLAY_PREFS` constant — luckily with the same
defaults, but a future drift between them would compile (structural
typing) yet ship incoherent UI vs server defaults. The two typenames
are reachable from the same call graph (`use-chart-overlay-prefs.ts`
imports from `dashboard-layout`; `chart-overlay-controls.tsx`
declares its own), so a reviewer reading the chart's render path now
has to switch between `ChartOverlayPrefs` and
`ChartOverlayPrefsValue` mentally.
- **Recommendation:** Drop the duplicate from
`chart-overlay-controls.tsx` and re-import `ChartOverlayPrefs` +
`DEFAULT_CHART_OVERLAY_PREFS` from `@/lib/dashboard-layout` (the
canonical home). 5-line change. The `ChartOverlayPrefsValue` name was
presumably introduced because the controls component pre-dated the
persistence layer; now that `dashboard-layout` is the source of
truth, the duplicate is dead weight.
- **Ship-blocker:** No.
### H3 — `src/components/insights/recommendation-card.tsx` (417 lines) is approaching the size where the named-slot pattern alone won't scale — 5-bucket plug-in pressure now bears on `<RationaleCard>`'s prop list (6 props, plus 5 derived feedbackProps)
### F2 — `getAchievementCategory` deprecated but kept as a public export
- **File**: `/Users/marc/Projects/HealthLog/src/components/insights/recommendation-card.tsx`
- **Issue**: The named-slot architecture (`data-slot="rec-confidence-slot"`,
`data-slot="rec-feedback-slot"`) was the right call in B5c — five
buckets plugged in across B5c/d/e without merge conflicts. But the
result is now a 417-line file where:
- `RationaleCard` takes 6 props (`rationale`, `metricSource`,
`referenceId`, `confidence`, `locale`, `feedbackProps`)
- `feedbackProps` itself is a 5-field object built inline at the
parent level via `asFeedbackTimeRange()` defence-in-depth
- Three local helpers (`metricTypeToChartTypes`,
`isMoodMetric`, `isComplianceMetric`) duplicate vocabulary that
already lives in `src/components/charts/mini-window.ts` and
`src/lib/insights/chart-tokens.ts`
- `<CitationFootnote>` is defined inline AND consumed twice
(expanded rationale + non-expandable legacy fallback)
The component is still readable today, but the next bucket (e.g.
B5f data-export-per-rec, or a v1.4.17 "share rec" affordance)
will push it past 500 lines.
- **Recommendation**:
- Extract `metricTypeToChartTypes` + `isMoodMetric` +
`isComplianceMetric` to `src/lib/insights/rec-metric-routing.ts`
(5-line file, but turns three duplicated `.toLowerCase()`
branches into a single source of truth shared with
`<InlineCharts>`).
- Extract `<CitationFootnote>` to
`src/components/insights/citation-footnote.tsx` — already used
twice, no reason to keep it inline.
- Extract `<RationaleCard>` to
`src/components/insights/recommendation-rationale-card.tsx` —
that's the natural split point, and it lets the prop list shrink
by hiding the 5 feedback fields behind a single `rec` parameter.
- Keep `asFeedbackTimeRange()` next to where it's used — it's
defence-in-depth for the API contract, not a candidate for
extraction.
After this split `recommendation-card.tsx` drops to ~250 lines and
the named-slot pattern keeps working for the next bucket.
- **Ship-blocker?**: no. Pre-emptive split for v1.4.17.
---
## MEDIUM
### M1 — `src/lib/ai/provider-runner.ts` carries TWO near-identical fallback runners (`runWithFallback` + `runRawCompletionWithFallback`) with a "v1.4.17 will delete the legacy variant" comment — premature abstraction OR live-bridge debt
- **File**: `/Users/marc/Projects/HealthLog/src/lib/ai/provider-runner.ts`
lines 229-371 (143-line duplication)
- **Issue**: Two parallel async functions that differ only in the
inner call site (`generateInsight()` vs
`provider.generateCompletion()`). Both run the same chain
walkthrough, both classify hard failures the same way, both reach
for the same `applyLastWorkingCache` + `summariseError` helpers,
both emit the same Wide-Event annotations. The comment at line 296
says "v1.4.17 will migrate this route to the strict wrapper… at
that point this helper is removed." If that's true, the duplication
is a one-cycle bridge — fine. If v1.4.17 slips, the duplication
doubles every fallback-policy change.
- **Recommendation**: Either:
- **Inline-extract the chain walker** into a generic
`runChain<T>(chain, attempt)` higher-order function and call it
from both wrappers (~40-line reduction). Today.
- OR commit to the v1.4.17 deletion as a calendar item and add a
`// REMOVE: v1.4.17` marker the cleanup phase can grep for.
Without one of those, the duplication will outlive its rationale.
### M2 — `<RecommendationFeedback>` reaches into `useAuth()` directly to scope the localStorage cache key; this couples a presentational rec-card slot to the auth subsystem
- **File**: `/Users/marc/Projects/HealthLog/src/components/insights/recommendation-feedback.tsx`
lines 33, 134, 137-154
- **Issue**: The component takes a 5-field props bag describing the
rec, then ALSO reaches into `useAuth()` to obtain `user.id` for
the localStorage cache key. The localStorage scoping by user id is
correct (a tour-launcher fix from H4 in v1.4.15 catch-up did the
same thing for the same reason), but the feedback button is a
"given a rec, render thumbs" leaf — it shouldn't know about auth.
The hydration race the component handles in render
(`hydratedFor !== user.id` — `setState` during render to dodge the
`react-hooks/set-state-in-effect` lint) is a 17-line dance to
satisfy a contract that could be `userId: string` on the props.
- **Recommendation**: Have the parent `<RecommendationCard>` pull
`user.id` from `useAuth()` once and pass it as `userId` into
`feedbackProps`. The feedback component drops to a pure
function-of-props leaf: simpler render, no more hydration dance,
one less hook call per rendered card. The `useMutation` call
stays — that's behaviour, not auth.
### M3 — `src/lib/ai/types.ts` (132 lines) and `src/lib/ai/schema.ts` (329 lines) STILL define two response shapes; v1.4.15 M6 flagged this and v1.4.16 added rationale + confidence to BOTH instead of finishing the migration
- **Files**: `src/lib/ai/types.ts`, `src/lib/ai/schema.ts`
- **Issue**: Last milestone's senior-dev review M6 called the
dual-schema state architectural smell. v1.4.16 doubled down:
- `aiRecommendationRationaleSchema` (schema.ts) AND
`insightRecommendationRationaleSchema` (types.ts)
- `aiRecommendationSchema.confidence` AND
`insightRecommendationSchema.confidence`
- `metricSource` defined twice with subtly different field
requirements (`schema.ts` requires `summary` non-empty;
`types.ts` makes it required-but-permissive)
Each duplicate field has comments saying "mirrors the strict
schema in schema.ts" but there's no enforcement that they actually
do — drift between the two would only show up when a cached
payload deserialises differently between the two parsers.
- **Recommendation**: Bite the migration. v1.4.17 must either:
- **Delete `insightResultSchema`** entirely; renderer migrates to
consume `AIInsightResponse` directly (the .passthrough() shape
already accepts both for cached payload back-compat).
- OR explicitly define `InsightResult = AIInsightResponse & {
classification, findings, ... }` in types.ts as an extension of
the canonical schema — single source of truth, types.ts becomes
the dashboard-renderer-specific projection.
Carrying two schemas through another milestone risks a real
divergence bug.
### M4 — `src/components/insights/recommendation-card.tsx`'s `metricTypeToChartTypes()` and `<InlineCharts>` in `insight-advisor-card.tsx` both maintain their own metric-type vocabulary; no shared source
- **Files**: `src/components/insights/recommendation-card.tsx`
lines 114-135, `src/components/insights/insight-advisor-card.tsx`
lines 216-230
- **Issue**: Two parallel mappings:
- `metricTypeToChartTypes()` maps lowercased snapshot keys
("bloodpressure", "weight", "bodyfat", …) to the
measurement-type enum strings.
- `INLINE_CHART_TITLE_KEYS` maps measurement-type enum strings
("WEIGHT", "BLOOD_PRESSURE_SYS", …) to i18n title keys.
Both grew with the same v1.4.16 phase. A future "add SLEEP_QUALITY
metric" requires editing both files, with no compile-time guard
that they stayed in sync.
- **Recommendation**: Extract one
`src/lib/insights/metric-vocab.ts` exposing
`MEASUREMENT_TYPES_BY_SNAPSHOT_KEY` + `MEASUREMENT_TITLE_KEYS_BY_TYPE`
+ helper `resolveMetricRoute(snapshotKey)`. Both consumers
import. Adds one file, eliminates a hidden coupling.
### M5 — `<RationaleCard>` takes 6 props that come from the same rec; pass the `rec` (or normalised `NormalisedRec`) instead
- **File**: `src/components/insights/recommendation-card.tsx`
lines 192-279, 396-402
- **Issue**: The `<RationaleCard>` callsite in
`<RecommendationCard>` reads:
```tsx
<RationaleCard
rationale={norm.rationale}
metricSource={norm.metricSource}
referenceId={norm.referenceId}
confidence={norm.confidence}
locale={locale}
feedbackProps={feedbackProps}
/>
```
Five of those six props are de-structurings of `norm`. The prop
list will only grow as future buckets plug in. Compare with
`<RecommendationFeedback>` which already takes a rec-shaped props
object.
- **Recommendation**: `<RationaleCard rec={norm} feedbackProps={feedbackProps} locale={locale} />`.
Keeps `feedbackProps` separate (it's not a rec field — it's a
derived "is the rec submittable for feedback?" gate) and `locale`
separate (it's a user-context read). The prop list compresses to
the two values that aren't already in `norm`.
### M6 — `i18n.insights.recommendation.*` namespace mixes 4 unrelated feature buckets at the same depth; B5e key naming `feedbackThanks` / `feedbackHelpful` / `feedbackAlreadyRated` is flat where rationale + confidence are also flat
- **File**: `messages/en.json` lines 701-722 (and `messages/de.json`
mirror)
- **Issue**: Under the single `insights.recommendation.*` namespace
live four sub-buckets:
- source / viewSource (B5a citation)
- rationale / rationaleWindow / rationaleComparedTo /
rationaleDeviation / rationaleExpand / rationaleCollapse (B5c)
- confidence / confidenceAria / confidenceHigh / confidenceMedium
/ confidenceLow / confidenceDraft (B5d)
- feedbackHelpful / feedbackNotHelpful / feedbackThanks /
feedbackConfirmed / feedbackAlreadyRated (B5e)
- legacyPayloadCta (B5c)
Five flat bullets sit alongside two nested-by-prefix groups. A
future contributor will add `feedbackXyz` and not know whether
to nest under `feedback: { … }` or stay flat. The codebase
convention (verified against `messages/en.json` line 1558 admin
scope: `hostMetrics: { title, load1, ... }` and 1569
`aiQuality: { title, loading, ... }`) is **nested objects per
feature bucket**.
- **Recommendation**: Re-shape to:
```json
"recommendation": {
"citation": { "source": "...", "viewSource": "..." },
"rationale": { "window": "...", "comparedTo": "...", "deviation": "...",
"expand": "...", "collapse": "..." },
"confidence": { "aria": "...", "high": "...", "medium": "...",
"low": "...", "draft": "..." },
"feedback": { "helpful": "...", "notHelpful": "...", "thanks": "...",
"confirmed": "...", "alreadyRated": "..." },
"legacyPayloadCta": "..."
}
```
Every consumer file gets a single-line `t()` rename;
`messages/de.json` mirrors. The existing
`sections-i18n-parity.test.ts` will catch any miss. The same
shape already works for `admin.hostMetrics` / `admin.aiQuality` /
`comparison.captionLastMonth` — apply consistently.
### M7 — `src/lib/ai/feedback-attribution.ts` reads the LATEST `insights.generate` audit row to attribute provider; this couples the feedback domain to the audit-log shape
- **File**: `src/lib/ai/feedback-attribution.ts` lines 70-86
- **Issue**: The function fetches the user's most recent
`auditLog` row with `action: "insights.generate"`, parses the
`details` JSON, and pulls `chainProviderType` (or fallback
`providerType`). The audit log is a structured-event export
surface — using it as the de-facto "what provider did the user
most recently use" is a coupling that won't survive an audit-log
schema change. The right home for "current active provider per
user" is either:
- a memoised read off `User.aiProviderChain` + the in-process
runner cache (`getLastWorkingProvider()`)
- or a dedicated `User.lastUsedProviderType` column written by the
runner
- or a dedicated AppSetting / cache table
- **Recommendation**: For v1.4.17, write the working provider into
a per-user cache column at the END of `runWithFallback()` (the
in-process cache already exists; the row is a 1-line schema
add). The audit-log scan stays as a fallback for users whose
cache hasn't been hydrated yet, but the primary read is from a
dedicated column. As-is the feedback aggregator's slice is at the
mercy of the audit-log retention policy.
### M8 — `src/lib/jobs/feedback-aggregator.ts`'s `buildFeedbackBuckets()` joins keys with empty separator (`[severity, metricSourceType, providerType, promptVersion].join("")`); two values "ab" + "cd" collide with one value "abcd"
- **File**: `src/lib/jobs/feedback-aggregator.ts` line 75
- **Severity:** LOW
- **File:** `src/lib/gamification/achievements.ts` (line ~136-141)
- **Issue:**
```ts
const key = [
row.recommendationSeverity,
row.metricSourceType,
row.providerType,
row.promptVersion,
].join("");
/**
* @deprecated kept only for legacy callers; new code should read
* `AchievementDefinition.category` directly. v1.4.18 made category a
* stored field on the definition so hidden Easter-eggs (which share
* metrics with no other badge) don't need a special case.
*/
export function getAchievementCategory(...)
```
- **Issue**: Empty-string join means `["bp","weight","openai","4.16"]`
and `["bpweight","openai","","4.16"]` both produce `"bpweightopenai4.16"`.
Today the inputs are constrained enough (severity is enum,
providerType is enum, promptVersion is `4.16.0` with dots) that
collision is unlikely. But the schema change that adds a hyphen-
containing severity tomorrow could silently merge buckets.
- **Recommendation**: Use a delimiter that can't appear in any of
the four values — convention in this codebase is `::` (see
`findUncitedRecommendations` line 310 in `schema.ts`). One-char
fix; eliminates a class of silent-collision bugs the test
fixtures wouldn't catch.
The JSDoc says "legacy callers" but `git grep` shows zero call sites
outside the test suite — the new design stores `category` on the
definition row directly, so the function is purely vestigial. Keeping
a `@deprecated` symbol public invites new code to reach for it.
- **Recommendation:** Either un-export it (rename to `categoryForMetric`
internal-only — already exists alongside it) and delete the
re-exported wrapper, or move it behind an `__internal__/` boundary.
This is a mild premature-abstraction red flag flagged in the brief
— the v1.4.18 refactor created the right thing (`category` stored on
the definition) but kept the old door open out of caution.
- **Ship-blocker:** No.
### M9 — `<HostMetricsChart>`, `<AppLogPreviewSection>`, `<AiQualitySection>` follow a TanStack Query pattern with inline `(await res.json()).data` unwrap; the TanStack-collision memory note from v1.4.15 says the convention is the unwrap, but the new admin sections re-implement it three times instead of using a shared `fetchJsonOrThrow` helper
### F3 — `src/lib/gamification/achievements.ts` (839 LOC) approaching the "definitions/ + evaluators/ + utilities/" split threshold
- **Files**: `src/components/admin/host-metrics-chart.tsx`,
`src/components/admin/app-log-preview-section.tsx`,
`src/components/admin/ai-quality-section.tsx`,
`src/components/admin/api-token-overview-section.tsx`,
`src/components/admin/backups-section.tsx`
- **Issue**: Five admin sections (and many more outside admin) all
hand-roll the same `fetch + res.ok + json.data ?? throw` mini
state machine. The error message is different in each
("ai_quality_failed" vs "Failed to load app logs" vs "Failed").
No shared helper means error formatting drifts.
- **Recommendation**: One small `src/lib/api-client.ts` helper:
- **Severity:** LOW
- **File:** `src/lib/gamification/achievements.ts`
- **Issue:** The file currently houses (in order): types,
`categoryForMetric`, `ACHIEVEMENT_CATEGORY_ORDER`, the `metrics`
interface, the `AchievementProgress` type, `BERLIN` formatters, the
`STREAK_TARGETS` + `buildStreakAchievements` factory, the `define()`
helper, the **38 definition literals** (~530 LOC of the file),
`dayKeyToNumber` / `toBerlinDayKey` / `getUniqueBerlinDays` /
`calculateLongestStreak`, the `evaluateAchievementsWithCompletionDates`
evaluator, and the `applyDiscoveryFilter` + `isEarnable` filter.
That's 4 distinct responsibilities (types, definitions data, day-key
utilities, evaluator+filter logic) under one roof. At 839 LOC it's
still readable, but the brief asked specifically: "is
`src/lib/achievements/` still coherent or does it need sub-folders
(definitions/ evaluators/ ui/)?". Today it is one file under
`gamification/`, not a folder.
- **Recommendation:** Defer the split to the first time we add a 5th
responsibility (e.g. a server-side rules engine, a cross-user leaderboard
aggregator, etc.). Right now the four halves are short enough that
inlining is faster to navigate than chasing imports across 4 files. If
a split is forced earlier, the natural cut points are:
- `gamification/achievements/definitions.ts` (the literal array +
`define()` factory)
- `gamification/achievements/evaluator.ts`
(`evaluateAchievementsWithCompletionDates`)
- `gamification/achievements/discovery.ts` (`applyDiscoveryFilter` +
`isEarnable` + `EarnabilityFlags`)
- `gamification/achievements/day-keys.ts` (`toBerlinDayKey`,
`calculateLongestStreak`, etc. — could even hoist to
`lib/dates/berlin-day-keys.ts` since `bp-in-target.ts` reimplements
`toBerlinDayKey` separately, see F4)
- **Ship-blocker:** No.
### F4 — `toBerlinDayKey()` reimplemented in two places
- **Severity:** LOW
- **Files:** `src/lib/analytics/bp-in-target.ts` (line 70) and
`src/lib/gamification/achievements.ts` (line ~680)
- **Issue:** Both files independently roll an `Intl.DateTimeFormat` with
`timeZone: "Europe/Berlin"` formatter and a `toBerlinDayKey()` helper
that returns `YYYY-MM-DD`. The bp-in-target version uses the en-CA
locale shortcut (`format()` returns `YYYY-MM-DD` directly); the
achievements version uses `formatToParts()` and stitches the parts
manually. Functionally identical, but the duplication means a future
timezone bug (DST edge, year-rollover, etc.) has to be fixed in two
places. `expansion-metrics.ts` re-imports from `achievements.ts` so it
doesn't add a third copy — but the two existing ones are independent.
- **Recommendation:** Promote one of them to a shared helper, e.g.
`src/lib/dates/berlin-day-key.ts` exporting `toBerlinDayKey()` +
`getUniqueBerlinDays()` + `calculateLongestStreak()`. Both achievements
and bp-in-target depend on it. Defer the move until a third caller
arrives — two copies aren't yet a smell, but `git grep "Europe/Berlin"`
inside `src/lib/` already finds 8+ matches across helpers, so the
pattern is recurring.
- **Ship-blocker:** No.
### F5 — Achievements route wears too many hats
- **Severity:** LOW
- **File:** `src/app/api/gamification/achievements/route.ts` (876 LOC)
- **Issue:** The route now mixes:
- 12 prisma queries with `Promise.all`
- 7 derived helpers (`getEventDaySeries`, `getHealthGreenDaySeries`,
`getOnTimePerfectDaySeries`, `getCompliance80DaySeries`,
`getIntakeIssueMetrics`, `findCountCompletionDate`,
`findStreakCompletionDate`) inlined as private functions
- The completion-date stitching logic (a 130-line `for...of
ACHIEVEMENT_DEFINITIONS` switch over each metric)
- Discovery-filter-aware summary recomputation
- iOS-format vs default-format response shape branch
At 876 LOC this is the largest API route in the codebase. The
v1.4.18 expansion grew it by 100 lines, and the next time a metric
type lands the same `if (definition.metric === "X")` chain extends.
- **Recommendation:** Extract the inlined helpers to
`src/lib/gamification/route-helpers.ts` so the route stays
responsible for "fetch + serialise" and the metric pipeline is
testable in isolation. The integration test file already calls out
it can't drive the full route end-to-end (pre-existing
`medication_schedules.days_of_week` migration drift) and falls back
to direct calls into `buildExpansionMetricValues` —
pulling those helpers into `route-helpers.ts` would let the
integration test exercise _all_ the route logic, not just the
expansion bits.
- **Ship-blocker:** No (but the trajectory deserves a note for the
v1.4.19 backlog).
### F6 — Hidden-discovery branch is dead-code defensive
- **Severity:** LOW
- **File:** `src/lib/gamification/achievements.ts` (line ~828)
- **Issue:**
```ts
export async function fetchJsonOrThrow<T>(input: RequestInfo, init?: RequestInit): Promise<T> {
const res = await fetch(input, init);
const body = (await res.json()) as { data: T; error?: string };
if (!res.ok) throw new Error(body.error ?? `Request failed: ${res.status}`);
return body.data;
}
case "nightOwlCount":
case "earlyBirdCount":
case "leapDayCount":
case "doctorPdfCount":
case "localeFlipCount":
// Hidden achievements are filtered by category check above; this
// branch is only reached if a hidden definition's category got
// misconfigured. Stay defensive.
return true;
```
Adoption is opportunistic; not a structural requirement, but a
natural cleanup pass. Already partially done: the existing
`getApiErrorMessage` helper in `backups-section.tsx` is similar.
Promote it.
This is exactly the "defensive code that can't fire" pattern the
brief calls out. The comment admits it: the `applyDiscoveryFilter`
short-circuit at line ~796 (`if (item.category === "hidden") return
true`) already covers every hidden definition, because the `define()`
helper hard-codes `category: isHidden ? "hidden" : ...`. The only way
this branch fires is if the `define()` helper itself breaks — which
has zero test coverage on this path.
- **Recommendation:** Either drop the case entries entirely (the
`switch` would then exhaust under `noImplicitReturn` thanks to the
exhaustive-check on `AchievementMetricKey`, which is what we want —
any new hidden metric SHOULD force a category review at compile
time) or drop the comment and leave the case but with `throw new
Error("hidden metric reached non-hidden filter — broken define()")`
to make the dead branch loud-fail rather than silent-pass.
- **Ship-blocker:** No.
---
### F7 — Mobile-strip fix lives at module level, but applies to TWO shells
## LOW
- **Severity:** INFO
- **File:** `src/app/globals.css` (lines 217-223) +
`src/components/admin/admin-shell.tsx` (line 160) +
`src/components/settings/settings-shell.tsx` (line 143)
- **Issue:** The `no-scrollbar` utility is correctly consolidated to
ONE place (globals.css) and consumed by both shells via the same
class name — that is the right architecture. The shells themselves
duplicate the surrounding `<nav className="no-scrollbar -mx-4 mb-4
overflow-x-auto px-4 md:hidden">` markup verbatim though, with
similar `<ul className="flex min-w-max gap-2">` wrappers and similar
pill-button classes. Two near-identical components.
- **Recommendation:** Defer. The shells legitimately differ on the
active-slug match path (`/admin/<slug>` regex returns `null` for
`/admin`; `/settings/<slug>` falls back to `"account"`) and the
desktop sidebar adds an "Overview" link in admin only. Sharing the
wrapper would require parameterising both behaviours. The 90 % code
overlap is the right amount of duplication for two surfaces that
read similar but mean different things — the alternative is a 5-prop
`<SectionShell>` that nobody else uses. Note for v1.5+ if a third
section shell shows up.
- **Ship-blocker:** No.
### L1 — `src/components/insights/insight-advisor-card.tsx` lines 284-286 has a duplicated `// ─── Section Separator ───` comment header
### F8 — `findClosestDia` is O(n·m); dataset stays small enough that it's fine
- **File**: `src/components/insights/insight-advisor-card.tsx`
- **Issue**: Two consecutive `// ─── Section Separator ───`
comment headers, no code between them. Copy-paste residue from a
marathon edit.
- **Recommendation**: Delete the duplicate. One-line fix.
- **Severity:** INFO
- **File:** `src/lib/analytics/bp-in-target.ts` (line 80)
- **Issue:** For each sys reading we scan the full dia array
(`O(n·m)`). With Marc's actual fixture (10 paired readings over
30 days, 250 paired over 30 days for a Withings-paired user) this is
fine. At 10k readings (a multi-year backfill replayed in one shot)
the route would slow noticeably. The function isn't part of any
background job — only the analytics route — and the route already
caps at 30 days, so the worst case is hundreds of readings, not
tens of thousands.
- **Recommendation:** No change. If a future "comprehensive" or
multi-year insight surface ever uses this helper, swap to a pre-sorted
dia array + binary search. Pin a benchmark test if/when that moves.
- **Ship-blocker:** No.
### L2 — `<RecommendationFeedback>`'s 17-line `if (!initialState && user && hydratedFor !== user.id)` block in render-time is a workaround for `react-hooks/set-state-in-effect` that future readers will trip over
## Cross-cutting checks
- **File**: `src/components/insights/recommendation-feedback.tsx`
lines 137-154
- **Issue**: The set-state-during-render dance is correct (matches
the lazy-init pattern documented in CLAUDE.md), but the block has
no comment explaining why it's not in `useEffect`. A code reviewer
unfamiliar with the project rule would flag it as a bug.
- **Recommendation**: One-line comment: `// CLAUDE.md rule: avoid setState in useEffect; recompute from props during render is the project pattern (matches tour-launcher.tsx).`
- **Logging via `annotate()`:** Verified. The new
`/api/dashboard/chart-overlay-prefs` route and the analytics route
both call `annotate({ action: { name: "..." } })` on the success
path. The achievements route already had its annotation in v1.4.17
and the v1.4.18 patch didn't drop it.
- **Errors via `HttpError`:** The new overlay-prefs route uses
`apiError("...", 422)` for Zod validation failures (the canonical
pattern across the codebase) instead of throwing — same approach as
the rest of `/api/dashboard/*`. Consistent.
- **Sensitive data via `crypto.ts`:** v1.4.18 introduces no new
encryption-relevant paths. Achievements + chart-overlay-prefs are
both plain-text user prefs persisted on `User.dashboardWidgetsJson`,
no PHI / token material at rest. Correct call.
- **Test architecture:**
- `src/lib/gamification/__tests__/expansion-metrics.test.ts` (280
LOC) covers all 5 evaluator helpers with table-driven cases.
- `src/lib/gamification/__tests__/achievements.test.ts` (191 LOC)
covers the discovery filter (3 exception paths) + category
grouping.
- `tests/integration/achievements-expansion.test.ts` (180 LOC) uses
`getPrismaClient()` + `truncateAllTables()` — the testcontainer
setup mandated by `CLAUDE.md`. Same shape as the v1.4.17
integration suite.
- `tests/integration/chart-overlay-prefs.test.ts` (182 LOC) — same
testcontainer setup; covers the resolver coercion path for
malformed PUT bodies.
- `tests/integration/bp-in-target.test.ts` (+169 LOC for the new
windowed cases) — extends the existing testcontainer suite.
- `e2e/achievements.spec.ts` and `e2e/chart-overlay-controls.spec.ts`
extend the Playwright + axe-core suite per `CLAUDE.md`.
Test coverage is comprehensive and matches the testing convention.
### L3 — `src/lib/ai/provider-chain.ts` and `src/lib/ai/provider.ts` both reference a `ProviderChainResolved` type; the type is defined in `provider-runner.ts` (the most-imported file) but the comment at provider-chain.ts top says "Why a separate module rather than inlining"
## TODO/FIXME inventory in v1.4.18-changed files
- **File**: `src/lib/ai/provider-chain.ts` lines 12-19
- **Issue**: `ProviderChainResolved` is the bridge type
(logical-type + instance) that both `provider-chain.ts` (parser)
and `provider-runner.ts` (executor) need. It's defined in
`provider-runner.ts` and re-imported by `provider.ts` — backwards
layering. The parser layer should own the type; the runner
consumes it. Today's layout means `provider-chain.ts` doesn't
know about it (it stops at metadata) and `provider.ts` imports
from `provider-runner.ts` for a type the runner doesn't author.
- **Recommendation**: When H1's directory restructure happens, move
`ProviderChainResolved` into `runtime/provider-chain.ts` (or a new
`runtime/types.ts`), so the parser can name what the runner
consumes. Trivial; do as part of the H1 split.
`grep -nE "(TODO|FIXME|HACK|XXX)"` across the 59 changed source files
returns **zero** matches. No deferred-work markers landed in this
release. Clean.
### L4 — `src/lib/ai/legacy-payload.ts` (55 lines) is a one-function module that could live in `schema.ts` next to `findRecommendationsMissingRationale()`
## Best-practice red flags
- **File**: `src/lib/ai/legacy-payload.ts`
- **Issue**: `isLegacyInsightPayload(payload: unknown): boolean` is
the only export. `schema.ts` already exports
`findRecommendationsMissingRationale(parsed: AIInsightResponse)`
for nearly the same legacy-detection use case. Two helpers, two
modules, same domain.
- **Recommendation**: Inline into `schema.ts` (or a new
`schema/legacy-detection.ts` after H1's split). Watch for the
type difference: `isLegacyInsightPayload` takes `unknown`,
`findRecommendationsMissingRationale` takes `AIInsightResponse` —
they cover different stages of the parse pipeline. Document the
staging in the merge.
### L5 — `<RecommendationCard>`'s `LOW_CONFIDENCE_CAPTION_THRESHOLD = 50` and `<ConfidenceMeter>`'s `DRAFT_THRESHOLD = 25` + 80 / 50 / 25 band cuts live in two separate files with no shared source
- **Files**: `src/components/insights/recommendation-card.tsx`
line 102, `src/components/insights/confidence-meter.tsx` line 37
- **Issue**: The confidence thresholds are split:
- `<ConfidenceMeter>`: 80 (high), 50 (medium), 25 (low/draft)
- `<RecommendationCard>`: 50 (low-confidence-caption)
- `src/lib/ai/confidence.ts`: no constants exposed (formula-only)
Three files, three sets of magic numbers. A future "tighten the
band cutoffs" change requires editing three places.
- **Recommendation**: Add a `src/lib/ai/confidence-bands.ts`
exposing:
```ts
export const CONFIDENCE_BANDS = {
DRAFT: 25,
LOW: 50,
MEDIUM: 80,
} as const;
```
Both components import. The threshold semantics become
self-documenting (`< CONFIDENCE_BANDS.LOW` reads better than
`< 50`).
### L6 — `src/lib/jobs/host-metric-sampler.ts`'s `getHostMetricRetentionDays()` reads env on every call; no caching, no obvious test reason
- **File**: `src/lib/jobs/host-metric-sampler.ts` lines 120-131
- **Issue**: Every minute the worker fires `runHostMetricTick()`
which calls `getHostMetricRetentionDays()` which calls
`process.env.HOST_METRIC_RETENTION_DAYS`. Cheap call, but the
pattern is inconsistent with the rest of the codebase. v1.4.15
M8 already flagged the parallel "lazy env vs frozen env" split
in integrations vs notifications; this adds a third site.
- **Recommendation**: Defer to the M8/L1 v1.4.17 cleanup that picks
one canonical pattern.
### L7 — `prisma/schema.prisma` the new `RecommendationFeedback` model uses `recommendationSeverity` / `recommendationText` (camel) but `metric_source_type` / `metric_source_time_range` (snake-via-@map); the JS-side names are camelCase but the db column names mix prefixed-with-feature and not
- **Files**: `prisma/schema.prisma` lines 833-840
- **Issue**: The model uses `recommendationSeverity` (prefixed with
feature noun) and `metricSourceType` (prefixed with feature noun
too — but on-the-wire and in the table). This is consistent with
the rest of the schema. But the v1.4.16 audit-log query uses
`actor` / `action` / `target` as filter keys; the
recommendation-feedback row uses `recommendationId` /
`recommendationText` / `recommendationSeverity` — the
feature-prefix scheme is verbose where context already pins the
feature. The same row could read cleaner as `id`, `text`,
`severity` (the model name already says it's recommendation
feedback).
- **Recommendation**: Cosmetic only — the prefixed naming is
defensible (the model is also the join target so prefixed names
read clearly under join queries). Flag for the v1.4.17 settings
audit (B6 follow-up) only if Marc has a feel-preference.
### L8 — `src/lib/ai/medical-references.ts` and `<CitationFootnote>` both render the citation label as `{org} {publishedYear} — {title}` — the format is duplicated rather than centralised in a `formatCitationLabel(ref, locale)` helper
- **Files**: `src/lib/ai/medical-references.ts`,
`src/components/insights/recommendation-card.tsx` line 173
- **Issue**: One UI surface today; the v1.4.17 "share rec" or
the doctor-report PDF would re-render the same citation in the
same shape. No helper means a typography change requires editing
two places.
- **Recommendation**: Add `formatCitationLabel(ref, locale)` to
`medical-references.ts`. Today's only consumer is
`<CitationFootnote>`; flagged here so the second consumer
copy-pastes from the helper rather than the component.
### L9 — `src/lib/jobs/reminder-worker.ts` (1,350 lines) is the v1.4.16 +70-line addition (host-metric + feedback-aggregator queue registrations) on top of an already-too-large worker module
- **File**: `src/lib/jobs/reminder-worker.ts`
- **Issue**: 1,350 lines hosting reminder dispatch, insight caching,
data-backup, host-metric sampling, feedback aggregation, queue
registration, and the pg-boss instance. The file is the single
entry point for every cron-style background job — no per-queue
module split. Each new queue is +70 lines at the bottom of a
huge file.
- **Recommendation**: v1.4.17 — split per queue under
`src/lib/jobs/queues/<queue-name>.ts`, each exporting a
`register(boss: PgBoss)` function. `reminder-worker.ts` becomes
a 50-line orchestrator that calls each `register()`. The host-
metric and feedback-aggregator additions in v1.4.16 already have
pure compute helpers split out (`host-metric-sampler.ts` /
`feedback-aggregator.ts`); the missing piece is the queue-config
+ handler-wiring split. Mirror what v1.4.15 did for notifications
(`src/lib/notifications/dispatcher.ts` orchestrates;
per-channel senders in `src/lib/notifications/senders/`).
---
## Cross-cutting observations (positive)
- **Worktree adoption worked**: Last milestone's H3 (parallel-agent
staging race) was the recurring complaint; v1.4.16 STATE.md
records `agent/b1b-insights-surface`,
`agent/b2-ai-provider-ux`, `agent/b5c-explainability`,
`agent/b5d-confidence`, `agent/b5e-feedback-loop`,
`agent/b6-settings-audit`, `agent/b8-comparison-views`,
`agent/wave-c-catchup` — all worktree-isolated. Cross-agent
collisions still happen (`9b01c86` carries both A6 and A7
files; `8d9f864` absorbed B3 chart + i18n; `2611bb4` got
hijacked by B1a's mood-chart) but the working tree on `main`
is correct in every case. The "wrong commit subject" outcomes
are diff-faithful, just message-misleading.
- **Named-slot pattern is a clear win**: `<RecommendationCard>`'s
`data-slot="rec-confidence-slot"` + `data-slot="rec-feedback-slot"`
let B5c, B5d, B5e ship in parallel without merge conflicts.
The pattern should be the v1.4.17 default for any component
that anticipates plug-in pressure.
- **Pure-function extraction is consistent**: B5d's `confidence.ts`
exposes `computeConfidence(inputs)` as a pure function with
unit tests; B8's `comparison-shift.ts` exposes
`shiftDailySeriesForward()` + `computeComparisonDelta()` the
same way; B5e's `feedback-aggregator.ts` exposes
`buildFeedbackBuckets()` separately. This is the right pattern
and the marathon used it consistently.
- **Test-architecture is correct**: `tests/integration/`
testcontainers for the new `admin-host-metrics`,
`insights-feedback`, `insights-provider-chain` /
`insights-provider-chain-put`, `cascade-delete`,
`export-per-type`. SSR component tests under `__tests__/`
alongside the source files. Pure-function helpers tested in
isolation. No mocks-test-themselves smells observed.
- **`apiHandler()` discipline holds**: every new API route
(`/api/insights/feedback`, `/api/insights/provider-chain`,
`/api/admin/host-metrics`, `/api/admin/audit-log`,
`/api/admin/audit-log/actions`, `/api/admin/app-logs`,
`/api/admin/ai-quality`, `/api/export/measurements`,
`/api/export/medications`, `/api/export/mood`,
`/api/export/full-backup`) wraps in `apiHandler` with
`requireAuth()` or `requireAdmin()`.
- **`annotate()` adoption is universal**: 42 `annotate()` call
sites in v1.4.16-touched non-test files; zero new `console.*`
calls (the only `console.*` greps in `reminder-worker.ts`
predate v1.4.16, and they're explicitly the pg-boss-error
fallback the worker has used since v1.3).
- **`HttpError` discipline holds**: every new API-route throw
that needs to escape `apiHandler` uses `HttpError` (verified:
`provider-chain/route.ts:87,98`, `backups/[id]/restore/route.ts:126`).
The other `throw new Error()` matches in the grep all live
inside TanStack Query `mutationFn` / `queryFn` callbacks where
the throw is internal to the React Query state machine.
- **Encryption discipline holds**: every new sensitive value goes
through `decrypt()` from `crypto.ts` (verified at
`provider.ts:59,67,84` for AI keys; nothing new in v1.4.16
reached for raw cipher).
- **TODO/FIXME inventory**: zero. `git grep -nE "(TODO|FIXME|HACK|XXX)"`
filtered to v1.4.16-changed files in `src/` returns no matches.
- **Comment quality**: comments paraphrase code in only one
observed location (L2 above); the marathon's "WHY not WHAT"
discipline shows.
The structural foundation continues to hold; the v1.4.16 findings
above are mostly "this could be cleaner before the next bucket
plugs in", not "this is wrong".
---
- **Premature abstraction:** Mild — F2 (deprecated wrapper kept) + F6
(defensive switch case for an impossible branch). Nothing
load-bearing.
- **Defensive code that can't fire:** F6 only.
- **Comments paraphrasing code:** None found. Comments overwhelmingly
explain _why_ (regression history, Marc-feedback links, design
rationale) — exactly the codebase house style.
- **Backward-compat shims:** The analytics route header comment calls
out the headline `bpInTargetPct` is "kept for backward compatibility
with cached client bundles" while the new windowed fields are
surfaced alongside it. That's a deliberate, documented additive
change — not a stale shim. Verified: `grep -rn bpInTargetPct\b src/`
shows the headline is read by the dashboard tile (latest), the
insight-card (BP status), and the analytics route itself. Removing it
before the cache-bust window would 500 a fresh-deploy / stale-cache
client. Correct call.
- **API additive vs. shape break (A1):** The bp-in-target route
payload extension is purely additive (new optional `bpInTargetPct7d`
- `bpInTargetPct30d` fields, headline `bpInTargetPct` unchanged).
The dashboard `AnalyticsData` interface declares them
optional-undefined. Old clients on stale bundles who only read the
headline see the same value they used to. New clients fall through
to "—" when the field is undefined. **No partial-view hazard.**
## Summary
- **0 CRITICAL** — nothing blocks v1.4.16 ship.
- **3 HIGH** — directory layout pressure on `src/lib/ai/` (H1),
god-component drift in `<InsightAdvisorCard>` (H2), pre-emptive
split for `<RecommendationCard>` before the next plug-in (H3).
- **9 MED/LOW** — premature-or-bridge duplication in the chain
runner (M1), auth coupling in feedback leaf (M2), dual schema
STILL not migrated from v1.4.15 (M3), shared metric-vocab
duplication (M4), `<RationaleCard>` prop-list growth (M5), i18n
namespace flattening drift (M6), audit-log coupling for feedback
attribution (M7), bucket-key collision risk (M8), shared
`fetchJsonOrThrow` opportunity (M9), plus seven LOW housekeeping
items.
CRITICAL: 0
HIGH: 0
MED: 1 (F1 type duplication)
LOW: 5 (F2 deprecated export, F3 file-size watchpoint, F4 day-key
duplication, F5 route extraction watchpoint, F6 dead defensive branch)
INFO: 2 (F7 shell duplication watchpoint, F8 perf O(n·m) note)
Recommended action: **ship v1.4.16**. File the three HIGH items
+ M3 (the carry-over from v1.4.15 M6) in the v1.4.17 backlog as
the AI-module restructure block. The MED/LOW items are
opportunistic; pick them up when adjacent code is touched.
v1.4.18 is architecturally clean. The achievements expansion adds
significant data (38 definitions across 6 categories, 5 expansion
metric helpers, hidden Easter-egg discovery filter) without breaking
the existing module shape — the new `expansion-metrics.ts` is a clean
counterpart to `achievements.ts`, both are pure functions over typed
prisma rows, and both have unit + integration coverage.
The chart revert is clean — `chart-gradient.tsx` is _deleted_ (not
deprecated-and-left-behind), the new `chart-overlay-controls.tsx` is
controlled-component-only (parent owns state), persistence piggy-backs
on the existing `dashboardWidgetsJson` blob (consistent with B8
comparison-baseline + earlier widget patterns).
A2's `no-scrollbar` utility is consolidated to ONE definition in
`globals.css` and applied at both shell call-sites with identical
markup. Single source of truth ✅.
A1 extends the analytics route additively — old field
(`bpInTargetPct`) preserved, new windowed fields
(`bpInTargetPct7d`, `bpInTargetPct30d`) added. Zero partial-view
hazard for stale clients.
No CRITICAL findings. No ship-blockers.
+187 -430
View File
@@ -1,473 +1,230 @@
# Phase D — Simplify/Refine review findings (v1.4.16)
# Phase D — SIMPLIFY review (v1.4.18)
Reviewer: simplify agent (parallel — phase-D wave, 1 of 6)
Scope: `git diff --name-only v1.4.15...HEAD` — v1.4.16 changed source files only.
Constraint: chart presentation polish (B1a/B1b) was the explicit milestone goal — visual choices are not flagged.
Mode: autonomous, no commits, no edits — output only.
Reviewer: simplify-auditor (one of six parallel D-phase reviewers)
Scope: `git diff --name-only v1.4.17...HEAD` (60 files, +5068 / -1685)
Tone: per CLAUDE.md — flag premature abstractions, defensive code that
can't fire, speculative options, what-comments, leftover shims, and
4+-callsite repetition. Empty list valid.
Findings ordered roughly by impact. "Apply autonomously? yes" = low-risk delete / inline; "no" = needs Marc's call.
Findings below are ordered by impact. "Apply autonomously" = whether
the smell is mechanical enough that a follow-up patch can land without
fresh design discussion.
---
## Finding 1 — `runWithFallback` (strict variant) + `RunWithFallbackParams` / `RunWithFallbackResult` are test-only
## F1 — Duplicate `DEFAULT_CHART_OVERLAY_PREFS` + parallel type
**File:** `src/lib/ai/provider-runner.ts:136-288`
**Type:** speculative / dead
**Why it's a smell:** v1.4.16 ships TWO variants of the chain runner: the legacy `runRawCompletionWithFallback` (raw provider call, no schema enforcement) and the strict `runWithFallback` (wraps `generateInsight()` for parsed schema-enforcement). Production is wired to ONE: `/api/insights/generate/route.ts:280` calls only `runRawCompletionWithFallback`. The strict `runWithFallback`, plus its `RunWithFallbackParams` and `RunWithFallbackResult` interfaces, are imported solely by `src/lib/ai/__tests__/provider-runner.test.ts`. The file's own header comment even documents the migration as "v1.4.17 will migrate this route to the strict wrapper (B5c work) — at that point this helper is removed."
This is two-implementations-of-one-interface in disguise: 60 LOC of strict-wrapper plumbing + 27 LOC of test-only types pre-built for a v1.4.17 migration that hasn't happened. CLAUDE.md: "Don't add features, refactor, or introduce abstractions beyond what the task requires."
Verified:
```
$ grep -rn "runWithFallback" src/app
(no output — only runRawCompletionWithFallback is wired)
```
The transitive cost is real: `generateInsight()` is itself only reachable via the strict variant, so `GenerateInsightOutcome`, the corrective-retry retry logic in `tryOnce`, `applyConfidenceOverride`, `annotateCitationCoverage`, and `InsightSchemaError` are all reachable only from tests in v1.4.16.
**Suggested change:** Pick one of (a) or (b):
- **(a)** Delete `runWithFallback`, `RunWithFallbackParams`, `RunWithFallbackResult` from `provider-runner.ts`. Migrate the test suite to exercise `runRawCompletionWithFallback` for fallback semantics + the standalone `generateInsight()` for schema-enforcement semantics. Keeps `generateInsight()` available for the v1.4.17 migration but removes the speculative coupling.
- **(b)** Wire `/api/insights/generate/route.ts` to the strict variant NOW (the route already gets a `ProviderChainResolved[]`; the swap is one import + one call site). Then delete `runRawCompletionWithFallback` + its result interface. This is the eventual migration the comment promises — doing it now collapses the duplication.
Path-of-least-resistance is (a); (b) is the more honest answer to the duplication.
**Risk:** medium — both options touch live AI code paths; (b) is bigger but resolves the duplication permanently.
**Apply autonomously?** no — needs Marc's call on whether v1.4.16 is the right milestone for the migration (b) or just to defer (a).
- **Files**:
- `src/components/charts/chart-overlay-controls.tsx:49-56,164-168`
- `src/lib/dashboard-layout.ts:121-131`
- **Type**: Repetition / parallel definition
- **Why it's a smell**: A3 declares `DEFAULT_CHART_OVERLAY_PREFS` in
_two_ modules (one for the controls component, one for the layout
resolver) with identical values. A second `ChartOverlayPrefsValue`
interface in `chart-overlay-controls.tsx` mirrors `ChartOverlayPrefs`
in `dashboard-layout.ts` field-for-field. Three modules
(`chart-overlay-controls.tsx`, `use-chart-overlay-prefs.ts`,
`dashboard-layout.ts`) each have one of the two copies; a fourth
(`chart-overlay-controls.test.tsx`) imports the controls-side copy.
If anyone ever adds a fourth toggle, half the surface will silently
drift.
- **Suggested change**: Delete the constant + interface in
`chart-overlay-controls.tsx` and re-export from `dashboard-layout.ts`
(or have the controls component `import` the canonical type+default).
`ChartOverlayControlsProps.prefs` becomes `ChartOverlayPrefs`.
- **Risk**: Very low — pure rename + re-import. Existing test imports
`DEFAULT_CHART_OVERLAY_PREFS` from `chart-overlay-controls`; either
keep the re-export or update the test (1 line).
- **Apply autonomously?**: yes
---
## Finding 2 — `GenerateInsightOptions.confidenceContext` is a config-bag with one shape
## F2 — `chartKey ?? "bp"` fallback feeds a hook whose result is then thrown away
**File:** `src/lib/ai/generate-insight.ts:197-275`
**Type:** speculative / premature abstraction
**Why it's a smell:** `generateInsight()` accepts an `options: GenerateInsightOptions` object whose only field is `confidenceContext?: ConfidenceContextResolver`. No production caller passes it; `provider-runner.ts:244` calls `generateInsight(candidate.instance, params)` with no third argument. Inside the function, the resolver always falls back to `DEFAULT_CONFIDENCE_RESOLVER`, which uses `metricSource.n` + `recencyDays=0` + `deviationStdRatio=null` — i.e. the formula has only ONE shape ever in production.
The doc-comment promises that "the route layer can supply a real value via the resolver", but the route layer doesn't. The whole `ConfidenceContextResolver` type, the `GenerateInsightOptions` interface, the `options.confidenceContext ?? DEFAULT_CONFIDENCE_RESOLVER` indirection, and the per-rec callback are scaffolding for a v1.4.17 enhancement (the file says "v1.4.17 will fit weights to thumbs-up rates"). CLAUDE.md: "Don't add config bags with one shape" / "Don't add features beyond what the task requires."
Verified:
```
$ grep -rn "confidenceContext" src/app
(no output)
```
**Suggested change:** Inline the default-resolver body into `applyConfidenceOverride`, drop the `options` parameter, drop the `GenerateInsightOptions` interface, drop the `ConfidenceContextResolver` type and the `DEFAULT_CONFIDENCE_RESOLVER` constant. The function becomes:
```ts
export async function generateInsight(
provider: AIProvider,
params: CompletionParams,
): Promise<GenerateInsightOutcome> { … }
function applyConfidenceOverride(parsed: AIInsightResponse): void {
const overrides: Array<{ id: string; model: number | null; computed: number }> = [];
for (const rec of parsed.recommendations) {
const computed = computeConfidence({
n: rec.metricSource.n ?? 0,
recencyDays: 0,
deviationStdRatio: null,
});
const modelValue = typeof rec.confidence === "number" ? rec.confidence : null;
rec.confidence = computed;
overrides.push({ id: rec.id, model: modelValue, computed });
}
…
}
```
When v1.4.17 actually needs the resolver injection, re-introduce it then with a real second caller — that's the time the abstraction earns its keep.
**Risk:** low — pure inlining. Tests that pass `options` need updating, but they're internal.
**Apply autonomously?** yes (with the caveat that one test will need its `confidenceContext: ...` passthrough removed; the test's existence is fine, just the `options` parameter goes).
- **File**: `src/components/charts/health-chart.tsx:321-324`
- **Type**: Defensive code that can't fire / odd contract
- **Why it's a smell**:
```ts
const overlayPrefs = useChartOverlayPrefs(chartKey ?? "bp");
const showMA = chartKey ? overlayPrefs.prefs.showTrendIndicator : false;
const showTrend = chartKey ? overlayPrefs.prefs.showTrendArrow : false;
const showBands = chartKey ? overlayPrefs.prefs.showTargetRange : false;
```
When `chartKey` is undefined the hook is mounted against the **wrong
key** ("bp"), and the resulting prefs are then discarded with the
ternary. Net effect: an extra TanStack-Query subscription on the
shared `["dashboard-layout"]` cache for every mini-mode chart
instance, plus a contract where `useChartOverlayPrefs` looks like it
wants a non-optional key but the caller fakes one. The mood-chart
side handles the same case more cleanly via a default param
(`chartKey = "mood"`) but only because it's a single-metric chart.
- **Suggested change**: Make `useChartOverlayPrefs` accept
`ChartOverlayKey | null`; when null, return
`{ prefs: DEFAULT_CHART_OVERLAY_PREFS, setPrefs: noop, isSaving: false }`
without subscribing to the query. Caller becomes
`useChartOverlayPrefs(chartKey ?? null)` and the three ternaries
collapse to `overlayPrefs.prefs.show*`.
- **Risk**: Low — narrow change, covered by the existing
`health-chart-overlay-defaults` test which already exercises the
no-`chartKey` path.
- **Apply autonomously?**: yes
---
## Finding 3 — `aiRecommendationRationaleSchema.referenceId` is a speculative optional field with no consumer
## F3 — `computeBpInTargetPct` + `computeBpInTargetWindows` both run on the same input
**File:** `src/lib/ai/schema.ts:119-126`
**Type:** speculative
**Why it's a smell:** The rationale schema accepts an OPTIONAL `referenceId` field on top of the rec-level `referenceId` (line 169). The doc-comment is candid: "this field stays optional so a future provider that wants to attach a reference at rationale-grain can do so without a schema bump." No production code reads `rationale.referenceId`; no UI surface displays it; the citation footnote in `recommendation-card.tsx:148` reads `norm.referenceId`, not `norm.rationale?.referenceId`. CLAUDE.md: "Don't add features … beyond what the task requires." The field is a v1.4.17+ option dressed up as part of the v1.4.16 contract.
Verified:
```
$ grep -rn "rationale\.referenceId\|rationale\?.referenceId" src
(no output)
```
**Suggested change:** Drop `referenceId: z.string().optional()` from `aiRecommendationRationaleSchema` (and its doc-comment block, lines 119-125). When v1.4.17 actually needs rationale-grain citations, re-add the field then.
**Risk:** low — optional, silent drop has zero runtime effect; only widens the parser's reject-set, which is the conservative direction.
**Apply autonomously?** yes.
- **File**: `src/app/api/analytics/route.ts:84-107`
- **Type**: Speculative redundancy / future-proofing
- **Why it's a smell**: After A1, the headline `bpInTargetPct` is
re-computed via `computeBpInTargetPct(sysData, diaData, …)` and
_then_ `computeBpInTargetWindows(sysData, diaData, …, now)` runs
again — its `last30Days.pct` is mathematically identical to the
headline because both filter to the last 30 days and use the same
pairing helper. The inline comment says the first call is "kept
independent so a future refactor (e.g. headline → 'today's reading
in target?') can move without breaking the sub-values" — that's
speculative; today both fields are the same number.
- **Suggested change**: Drop the standalone `computeBpInTargetPct`
call; set `bpInTargetPct = windows.last30Days?.pct ?? null` from
the windows result. Keep the two exported helpers (they serve
different unit-test surfaces). One DB-pair pass becomes one
helper call instead of two.
- **Risk**: Low — pure refactor, the test suite covers both shapes.
If the future "today's reading" pivot lands, that's the moment to
reintroduce a separate helper.
- **Apply autonomously?**: yes
---
## Finding 4 — `applyLastWorkingCache` doc-comment overstates the guarantee
## F4 — Redundant `.map(...)` shaping prisma rows that already match the target type
**File:** `src/lib/ai/provider-runner.ts:193-211`
**Type:** comment (WHAT, slightly misleading)
**Why it's a smell:** The doc-comment says "Returns a NEW array; original is not mutated." That's true but misleading — the inner `splice` does mutate the COPY (`reordered.splice(idx, 1)`); the only real contract is that the input array is left intact. The "NEW array" caveat is also defensive against a non-existent caller pattern: nothing in the code path stores or compares the input array reference. CLAUDE.md: "Default to writing no comments. Don't explain WHAT — well-named identifiers do that."
**Suggested change:** Trim the comment to just the WHY:
```diff
-/**
- * Reorder the input chain so the cached "last working" provider is
- * tried first. Returns a NEW array; original is not mutated. When the
- * cache is cold (or the cached provider is not in the input list), the
- * original order is preserved.
- */
+/**
+ * Reorder the input chain so the cached "last working" provider is
+ * tried first. Cold cache or unknown provider → original order.
+ */
```
**Risk:** low — comment-only.
**Apply autonomously?** yes.
- **File**: `src/app/api/gamification/achievements/route.ts:603-612`
- **Type**: Tiny noise
- **Why it's a smell**: The prisma `select` already pulls exactly
`{ date, score, moodLoggedAt }` — the same three fields the
`MoodEntryRecord` type declares — yet the route maps each row into
a fresh object literal of the same shape before passing to
`buildExpansionMetricValues`. Wasted allocation + 4 lines of noise.
- **Suggested change**: `moodEntries: moodEntries,` (or just inline
the array). Type matches structurally.
- **Risk**: Trivial.
- **Apply autonomously?**: yes
---
## Finding 5 — `RecommendationFeedbackSeverity` / `RecommendationFeedbackTimeRange` types duplicated from validation schema
## F5 — Unreachable defensive branch in `isEarnable`
**File:** `src/components/insights/recommendation-feedback.tsx:35-45`
**Type:** repetition (mild)
**Why it's a smell:** The component declares its own union types for severity and time-range:
```ts
export type RecommendationFeedbackSeverity = "info" | "suggestion" | "important" | "urgent";
export type RecommendationFeedbackTimeRange = "last7days" | "last30days" | "last90days" | "allTime";
```
These are byte-for-byte the same as the `z.enum(...)` lists in `src/lib/validations/recommendation-feedback.ts:18-30`. Two source-of-truth sites for the same closed enum invite drift — if v1.4.17 adds `"last180days"` to the validator, this component will silently accept-but-fail-to-display the new range until someone notices.
The repetition is small (4+4 strings) so on its own it sits below CLAUDE.md's "three similar lines is better than a premature abstraction" bar — BUT the abstraction already exists (the zod schema). The cost is just exporting `z.infer<typeof recommendationFeedbackSeverity>` aliases from the validation file.
Cross-call: `recommendation-card.tsx:288-300` already has a hand-rolled `asFeedbackTimeRange` switch over the same four values to validate the rec's timeRange before rendering the feedback row. Three sites of the closed enum, all transcribed.
**Suggested change:**
```ts
// recommendation-feedback.tsx — import from canonical source
import type {
RecommendationFeedbackRequest,
} from "@/lib/validations/recommendation-feedback";
export type RecommendationFeedbackSeverity = RecommendationFeedbackRequest["recommendationSeverity"];
export type RecommendationFeedbackTimeRange = RecommendationFeedbackRequest["metricSourceTimeRange"];
```
Or even better: drop the local aliases entirely, use the canonical types directly. `asFeedbackTimeRange` in `recommendation-card.tsx` can also `import { recommendationFeedbackTimeRange }` and call `.safeParse(value).success ? value : null` — slightly more code than the switch but a single source of truth.
**Risk:** low — pure type-rewire.
**Apply autonomously?** yes (just the type re-export; leave `asFeedbackTimeRange` as-is until a fourth consumer appears).
- **File**: `src/lib/gamification/achievements.ts:794-838`
- **Type**: Defensive code that can't fire
- **Why it's a smell**: The five hidden-metric cases at the bottom
(`nightOwlCount`, `earlyBirdCount`, `leapDayCount`, `doctorPdfCount`,
`localeFlipCount`) carry a comment that explicitly says they're
unreachable: hidden achievements are filtered by the
`category === "hidden"` check earlier in `applyDiscoveryFilter`.
The five-case fall-through exists only to keep TypeScript's
exhaustive-switch happy. The function would be cleaner as an
exhaustiveness-checked switch with the hidden cases mapped to the
sentinel `assertNever` _or_ simply grouped with the other
no-precondition `return true` block.
- **Suggested change**: Merge those 5 cases into the existing
"no metric-data precondition" block (just append them after
`weekendStreakCount`). Drop the duplicate comment.
- **Risk**: Low — semantically identical (`return true`); the
existing comment already concedes this.
- **Apply autonomously?**: yes
---
## Finding 6 — `formatRelativeTime` justification "polyfill cost" is stale
## F6 — `getAchievementCategory` is a deprecation tag with no callers
**File:** `src/components/insights/insights-page-hero.tsx:43-66`
**Type:** comment (WHAT, factually questionable)
**Why it's a smell:** The doc-comment says the hand-rolled formatter "Avoids the `Intl.RelativeTimeFormat` polyfill cost on the client." `Intl.RelativeTimeFormat` has been baseline-supported in every browser HealthLog targets since 2018 (Chrome 71, Safari 14, Firefox 65) — there's no polyfill cost to avoid. The genuine WHY is the second sentence: "keeps every surface that renders relative times locked to the project's established translation vocabulary" (i18n consistency). That's a real reason; the polyfill claim is noise.
CLAUDE.md: "Default to writing no comments. Don't explain WHAT" — and "WHAT" includes false-positive justifications.
**Suggested change:** Trim to the true WHY:
```diff
-/**
- * Format a relative time using the i18n translation keys. Avoids the
- * `Intl.RelativeTimeFormat` polyfill cost on the client AND keeps every
- * surface that renders relative times locked to the project's
- * established translation vocabulary.
- *
- * Buckets: <1min → just now; <60min → N min ago; <24h → N h ago;
- * else N days ago.
- */
+/**
+ * Bucketed relative-time using i18n translation keys so every surface
+ * shares vocabulary. Buckets: <1m, <1h, <24h, else days.
+ */
```
**Risk:** low — comment-only.
**Apply autonomously?** yes.
- **File**: `src/lib/gamification/achievements.ts:132-142`
- **Type**: Backward-compat shim with no callers
- **Why it's a smell**: v1.4.18 introduced a private
`categoryForMetric()` and made `category` a stored field on
`AchievementDefinition`. The old `getAchievementCategory` export
was kept "for legacy callers" — `grep -rn "getAchievementCategory"
src/ tests/ e2e/` shows zero callers outside the file itself.
- **Suggested change**: Delete the export and its JSDoc; rename
`categoryForMetric` → `getAchievementCategory` if any external
caller is anticipated, otherwise leave it private.
- **Risk**: Trivial — codebase-wide grep confirms no consumers.
- **Apply autonomously?**: yes
---
## Finding 7 — `RecommendationCard` "data-slot" architecture comment block is over-narrating
## F7 — Six "gradient removed" what-comments scattered through the chart wrappers
**File:** `src/components/insights/recommendation-card.tsx:22-47`
**Type:** comment (WHAT)
**Why it's a smell:** The header comment block lists every named slot the card exposes (`rec-confidence-slot`, `rec-feedback-slot`, etc.) along with the v1.4.16 phase tag for each. Half of those slots are now FILLED — `<ConfidenceMeter>` (B5d) and `<RecommendationFeedback>` (B5e) both shipped in v1.4.16 and are visible in the same file. The comment still talks about them as "future" and as "named slots so B5d (confidence) and B5e (feedback) can plug in without touching this file" — but B5d / B5e DID touch this file (lines 20, 274-276, 363-365). The slot-vocabulary comment now describes a state that no longer exists.
CLAUDE.md: "Don't comment what the code does — comment why" + "Backward-compat shims for paths that no longer exist".
**Suggested change:** Replace the architectural narration with a one-sentence WHY:
```diff
-/**
- * v1.4.16 phase B5c — Oura-style RecommendationCard.
- *
- * Each recommendation lives in its own collapsible card. The
- * collapsed row shows: severity badge + rec text + named slot for
- * the future confidence-ring (B5d) + chevron toggle.
- *
- * Expanding reveals:
- * - 3-row rationale card (Window / Compared to / Deviation)
- * - mini-chart pinned to the rationale's data window via
- * HealthChart's new `mini` + `windowOverride` props
- * - citation footnote (B5a) when `referenceId` resolves
- * - named slot for the future feedback-thumbs (B5e)
- *
- * Default state: collapsed. Chevron toggles `aria-expanded`. Plain-
- * string recs (legacy InsightResult shape) and recs without
- * `rationale` render as a non-expandable list item — there's nothing
- * to expand to. The legacy-payload CTA at the parent advisor card
- * level surfaces the regenerate prompt for those.
- *
- * Layout slots are deliberately named so B5d (confidence) and B5e
- * (feedback) can plug in without touching this file:
- *
- * - data-slot="rec-confidence-slot" → B5d ConfidenceRing
- * - data-slot="rec-feedback-slot" → B5e RecommendationFeedback
- */
+/**
+ * Collapsible recommendation card with severity badge, confidence
+ * meter, and an expandable rationale block (window / compared-to /
+ * deviation) + mini-chart. Plain-string and rationale-less recs
+ * render as a non-expandable row.
+ */
```
**Risk:** low — comment-only.
**Apply autonomously?** yes.
- **Files**:
- `src/components/charts/health-chart.tsx:971-973, 1252-1253`
- `src/components/charts/mood-chart.tsx:575, 782`
- `src/components/charts/medication-compliance-chart.tsx:310, 425`
- **Type**: WHAT-not-WHY comments / scarring
- **Why it's a smell**: Every former `<defs>` and `<Area>` site got a
`{/* v1.4.18 — gradient … removed; clean line only. */}` comment
marking the absence. The git revert commit + memory
`feedback_charts_visual_identity.md` already document the WHY; six
in-tree breadcrumbs to a removed primitive read as scar tissue.
Any new contributor reading the chart file does NOT need to know
there used to be a gradient there.
- **Suggested change**: Delete all six. The chart-polish unit tests
already pin the absence (`expect(html).not.toContain("chart-gradient-…")`).
- **Risk**: Trivial — pure comment removal.
- **Apply autonomously?**: yes
---
## Finding 8 — `RecommendationCard.normalise()` mirrors the public `InsightRecommendation` shape almost verbatim
## F8 — `ChartOverlayPrefs` shape parallelism (considered + rejected)
**File:** `src/components/insights/recommendation-card.tsx:65-92`
**Type:** repetition / premature abstraction
**Why it's a smell:** The component declares a private `NormalisedRec` interface and a `normalise(rec)` helper that maps the union `InsightRecommendation` (string | object) into a struct with default-undefined fields. But the only "normalisation" the helper performs on the object branch is field-by-field copy (`text: rec.text, severity: rec.severity, …`) — every field is forwarded with the same name and type. The string branch produces `{ text: rec }`. So the entire `NormalisedRec` is just `{ text: string } | InsightRecommendationObject` collapsed to a single shape with all fields optional.
A simpler shape: a single helper `recAsObject(rec) → InsightRecommendationObject` that wraps the string branch:
```ts
function recAsObject(
rec: InsightRecommendation,
): { text: string; severity?: ...; rationale?: ...; … } {
return typeof rec === "string" ? { text: rec } : rec;
}
```
The `NormalisedRec` interface adds nothing the union type doesn't already say. This isn't blocking, but it's the smell of an abstraction that doesn't pull weight — particularly because the resulting fields (`norm.severity`, `norm.text`, etc.) are accessed identically to how a non-normalised object branch would be.
**Suggested change:** Replace `normalise` + `NormalisedRec` with the inline `typeof rec === "string"` check at the use site, OR with a single ~3-line `recAsObject` helper. Drop the interface.
**Risk:** medium — the normalised shape is consumed in 8+ sites in the same file; the rewrite is mechanical but spans the JSX. Worth a careful rebuild rather than a regex find-replace.
**Apply autonomously?** no — refactor touches the rendered DOM and warrants Marc's eye on the simplified version.
- **File**: `src/lib/dashboard-layout.ts:121-125`
- **Type**: Marginal / shape-only observation, not a fix
- **Why it's _not_ a smell**: Three distinct toggle names (trendIndicator,
trendArrow, targetRange) with three distinct semantics. Same shape
across all five chart keys is intentional (every chart has the same
three knobs). Per CLAUDE.md "speculative options" rule, this only
becomes a problem if a future chart needs a _different_ set of
toggles — at which point the per-chart prefs would need to vary
anyway. Documented here to confirm I considered + rejected this.
- **Apply autonomously?**: n/a (no action)
---
## Finding 9 — `pickProviderType` "PROMPT_VERSION snapshot" comment describes a missing feature
## Cleanup verifications
**File:** `src/lib/ai/feedback-attribution.ts:46-62`
**Type:** comment (WHY that doesn't fit the code)
**Why it's a smell:** The doc-comment for `pickProviderType` explains a per-call PROMPT_VERSION snapshot that the function does NOT do — the function only picks the providerType. The PROMPT_VERSION reading happens at the bottom of `resolveFeedbackAttribution()` (line 84) where it reads the constant directly, not from `details`. The "the current PROMPT_VERSION constant is always returned because the generator itself doesn't yet stash a per-payload prompt version" sentence is in the wrong function's doc-block — it belongs above `resolveFeedbackAttribution` (or above the constant import), not above `pickProviderType` which doesn't deal with PROMPT_VERSION at all.
CLAUDE.md: "Comment WHY — only when the WHY is true and visible from the code."
**Suggested change:** Trim `pickProviderType`'s doc-comment to just the priority order; move (or drop) the PROMPT_VERSION sentence:
```diff
/**
- * Pure helper exported for unit tests so we can pin the priority order
- * (chainProviderType > providerType > "unknown") without hitting the
- * DB. The current PROMPT_VERSION constant is always returned because
- * the generator itself doesn't yet stash a per-payload prompt version
- * (research §3.B notes this is a v1.4.17 ratchet item; reading the
- * constant snapshots whatever's deployed when the rating fires).
+ * Pick provider attribution from a parsed audit-row details blob.
+ * Priority: chainProviderType > providerType > "unknown".
*/
export function pickProviderType(details: AuditDetailsShape): string {
```
**Risk:** low — comment-only.
**Apply autonomously?** yes.
- **A3 chart revert leftovers** — `src/components/charts/chart-gradient.tsx`
is deleted (commit `008a8fb`); zero residual `import` references; the
test references in `mood-chart-polish.test.tsx`,
`medication-chart-polish.test.tsx`, and `health-chart-polish.test.tsx`
are negative assertions (`expect(...).not.toContain(...)`) which
should stay. **Clean.**
- **A2 no-scrollbar utility** — defined once in `globals.css:217-223`,
consumed by `admin-shell.tsx:160` + `settings-shell.tsx:143`. No
inline `scrollbar-width: none` duplication anywhere else. The two
consumers carry their own (slightly different) justification
comments, so they're not a copy-paste. **Clean.**
- **B1 evaluator pairs** — `getMoodMetrics`, `getEngagementMetrics`,
`getHiddenMetrics`, `countMeasurementsByType`, `getEarnabilityFlags`
are all exported and each have unit tests in
`expansion-metrics.test.ts`. The single composite caller
(`buildExpansionMetricValues`) is itself a route helper. Splitting
was the right call — each helper is independently testable.
**No simplification needed.**
- **`movingAverageByPoints` / `buildTrendLine` /
`buildTrendSeriesByTime` duplication between `health-chart.tsx` and
`mood-chart.tsx`** — flagged by `grep` but pre-dates v1.4.18
(verified against `v1.4.17:src/components/charts/mood-chart.tsx`).
Out of scope for this review.
- **`format === "percent"` dead branch** in
`src/app/achievements/page.tsx:92` — no `ACHIEVEMENT_DEFINITIONS`
entry uses `format: "percent"`, so the branch is dead. Pre-existed
v1.4.18 (the branch lived in v1.4.17 too). Out of scope; flag for a
future cleanup pass.
---
## Finding 10 — `legacy-payload.ts` `UnknownRec` interface is a single-use throwaway
## Tally
**File:** `src/lib/ai/legacy-payload.ts:18-21`
- 7 actionable findings (F1–F7), all low-risk + autonomous-safe.
- 0 architectural rewrites required.
- 0 cross-file ripple risks.
**Type:** speculative
**Why it's a smell:** Module declares an `interface UnknownRec { rationale?: unknown }` that is referenced exactly once, two lines down, in a cast: `(rec as UnknownRec).rationale`. That's a 3-line abstraction for a 1-line need. The cast can be inline:
```ts
const rationale = (rec as { rationale?: unknown }).rationale;
```
…and the `UnknownRec` interface deleted.
**Suggested change:** Inline the cast, delete the interface.
```diff
-interface UnknownRec {
- rationale?: unknown;
-}
-
/**
* Returns true when the payload is non-empty and at least one
* recommendation lacks a rationale object …
@@ -45,7 +41,7 @@ export function isLegacyInsightPayload(payload: unknown): boolean {
// Structured rec: rationale must be a populated object.
if (rec === null || typeof rec !== "object") return true;
- const rationale = (rec as UnknownRec).rationale;
+ const rationale = (rec as { rationale?: unknown }).rationale;
if (
rationale === undefined ||
```
**Risk:** low.
**Apply autonomously?** yes.
---
## Finding 11 — `SEVERITY_BORDER_CLASSES` duplicated across `recommendations-grid.tsx` and `insights-card.tsx`
**Files:**
- `src/components/insights/recommendations-grid.tsx:64-69` (+ `SEVERITY_BORDER_FALLBACK` line 71)
- `src/components/insights/insights-card.tsx:40-45` (+ `SEVERITY_BORDER_FALLBACK` line 47)
**Type:** repetition (anti-finding — flagged for record-keeping)
**Why it's NOT a finding to apply yet:** Identical 4-entry severity → border-class map plus a 1-line fallback constant. CLAUDE.md: "three similar lines is better than a premature abstraction." Two callsites; the 5-line table is a borderline candidate. A third callsite (the dashboard tile preview, the doctor-report PDF, …) would tip the balance.
The siblings ALSO have a `SEVERITY_RANK` table and `SEVERITY_BADGE_STYLES` table (in `recommendation-card.tsx` for the latter); collectively that's 3 tables × ~5 entries × 4 severities each. Collapsing them into a single `severity-tokens.ts` module ("urgent" → { rank, borderClass, badgeClass, heroWrapper }) would be the move when a 3rd consumer appears OR when the v1.4.17 prompt-version-aware coloring lands. Not yet.
**Suggested change:** None — flag for the next reviewer so the abstraction isn't proposed prematurely.
**Apply autonomously?** N/A.
---
## Finding 12 — `confidence-meter.tsx` band-comment block over-explains a 4-line policy
**File:** `src/components/insights/confidence-meter.tsx:4-23`
**Type:** comment (WHAT)
**Why it's a smell:** 20-line header comment explains the band-thresholds (>=80 / 50..79 / 25..49 / <25) and the colour mapping. Lines 46-51 already define `bandFor()` and lines 66-80 already declare the colour-by-band records — the code IS the documentation. CLAUDE.md: "Default to writing no comments. Don't explain WHAT — well-named identifiers do that." The genuine WHY is the "quiet-when-unsure" Apple research stance, but that's already a 1-liner inside `bandFor`'s context.
**Suggested change:** Replace the header block with the WHY-only:
```diff
-/**
- * v1.4.16 phase B5d — `<ConfidenceMeter>`.
- *
- * Renders a deterministic 0..100 confidence score (server-computed by
- * `computeConfidence()` and overridden in `generateInsight()`) as
- * either a 5-bar meter (default) or an SVG ring.
- *
- * Color bands match research §2.A "three-band visual" extended to 4
- * bands so the under-50 region splits into "low" + "draft":
- *
- * - >=80: green ("high"), 5 bars / full ring
- * - 50..79: yellow ("medium"), 3-4 bars
- * - 25..49: orange ("low"), 2 bars / partial ring + caption
- * - <25: "draft" pill REPLACES the meter — Apple's
- * "quiet-when-unsure" affordance per research §2.A. The rec is
- * drafted but the model isn't asserting it.
- *
- * aria-label always announces the numeric score so a screen reader
- * isn't blind to the visual band distinction. Mobile-friendly: the
- * bars variant fits in 56 px wide; the ring variant in 28×28.
- */
+/**
+ * Renders a 0..100 confidence score as a 5-bar meter (default) or SVG
+ * ring. Sub-25 replaces the meter with a "draft" pill ("quiet-when-
+ * unsure"). aria-label always carries the numeric value.
+ */
```
**Risk:** low — comment-only.
**Apply autonomously?** yes.
---
## Anti-findings (deliberately NOT flagged)
- **`shiftDailySeriesForward` / `averageValue` / `computeComparisonDelta` (`src/lib/charts/comparison-shift.ts`)** — three pure helpers, each with a clear WHY-comment about the v1.4.16 B8 brief + DST safety. All three are consumed by the dashboard tile-strip AND the chart wrapper. Real callers, narrow surface, justified.
- **`computeWindowTrend` (`src/lib/analytics/window-trend.ts`)** — pure helper extracted from inline chart logic; replaces a known-buggy "+0.0 kg/Woche" surface. Doc-comment explains the v1.4.16 A8b bug + the split-half rationale. Justified.
- **`isBpReadingInTarget` / `computeBpInTargetPct` (`src/lib/analytics/bp-in-target.ts`)** — file-header doc-comment is long but every paragraph is a numbered bug + fix narrative covering the v1.4.14 → v1.4.15 → v1.4.16 lineage. Genuine WHY for a regression-prone area; survives.
- **`prefersReducedMotion` (`src/lib/charts/reduced-motion.ts`)** — 25-line file for a `window.matchMedia` query that's used in 2 chart wrappers. Tiny, but the SSR-safe + try/catch shape repeats per-wrapper without it. Earned.
- **`HostMetricsChart.buildChartRows`** — exported pure helper for unit tests so the test doesn't render Recharts. Same pattern shipped in scatter-correlation-chart; consistent and useful.
- **`ConfidenceMeter`'s `variant` prop** — both `bars` (rec card, line 364) and `ring` (insights-card preview, line 107) are used in production. Two callsites with two different values; the prop pulls weight.
- **`provider-chain.ts` `parseProviderChain` + `serializeProviderChain` + `PROVIDER_CHAIN_DEFAULT`** — three callers (provider.ts resolver, provider-chain PUT route, parser-test). Real consumers + a defensive fallback path that the integration tests cover.
- **`legacy-payload.ts` `isLegacyInsightPayload`** — wired into `/api/insights/generate/route.ts:205` with a real CTA flow on the dashboard. Real consumer.
- **`feedback-attribution.ts` `resolveFeedbackAttribution`** — wired into the feedback API route + has a fallback constant. Real consumer.
---
## Summary
| # | File:Line | Type | Apply autonomously? |
|----|-----------|------|---------------------|
| 1 | `src/lib/ai/provider-runner.ts:136-288` (strict `runWithFallback` test-only) | speculative | no |
| 2 | `src/lib/ai/generate-insight.ts:197-275` (`confidenceContext` config-bag) | speculative | yes |
| 3 | `src/lib/ai/schema.ts:119-126` (`rationale.referenceId` unused) | speculative | yes |
| 4 | `src/lib/ai/provider-runner.ts:193-211` (over-stated comment) | comment | yes |
| 5 | `src/components/insights/recommendation-feedback.tsx:35-45` (type duplication) | repetition | yes |
| 6 | `src/components/insights/insights-page-hero.tsx:43-66` (stale polyfill claim) | comment | yes |
| 7 | `src/components/insights/recommendation-card.tsx:22-47` (slot-block over-narration) | comment | yes |
| 8 | `src/components/insights/recommendation-card.tsx:65-92` (`normalise()` indirection) | repetition | no |
| 9 | `src/lib/ai/feedback-attribution.ts:46-62` (mis-placed PROMPT_VERSION comment) | comment | yes |
| 10 | `src/lib/ai/legacy-payload.ts:18-21` (`UnknownRec` interface single-use) | speculative | yes |
| 11 | `SEVERITY_BORDER_CLASSES` 2× — anti-finding | repetition | n/a |
| 12 | `src/components/insights/confidence-meter.tsx:4-23` (band-block WHAT-comment) | comment | yes |
**8 yes / 2 no** (excluding anti-finding 11).
---
## Notes for the dispatcher
- Findings 1 + 2 + 3 are clustered — they all stem from the same v1.4.17-scaffolding pattern (test-only strict variant, config-bag with one shape, optional speculative field). If Marc decides to defer the strict-variant migration to v1.4.17, all three could be tackled together in a single "tighten v1.4.16 AI surface" commit.
- Finding 5 + 11 both touch the severity-token vocabulary; if a third callsite appears before v1.4.17, both should collapse into a single `severity-tokens.ts` module. Until then, the type-import (F5) is cheap, the table-extract (F11) is not.
- All comment-only findings (4, 6, 7, 9, 12) are independently safe and could be rolled into a single hygiene commit.
done: 7 apply-yes, 0 apply-no
@@ -0,0 +1,346 @@
# v1.4.18 — Product-Lead Review
Author: Marc (strategic memo to self)
Date: 2026-05-10
Status: pre-release of v1.4.18; v1.4.17 hotfix live since the morning;
v1.4.16 live since the night before that.
This is the lens that sits above the QA reviewers (code, security,
design, senior-dev, simplify). The other agents are landing
CRITICAL/HIGH inline; I am writing this for the question
_Marc-three-weeks-from-now_ will ask: **what state is the app in after
v1.4.16 → v1.4.17 → v1.4.18 in 36 hours, and what does v1.5 actually
need to be?**
The previous review at `.planning/phase-D-product-lead-review.md` (the
v1.4.16 strategic memo) is the base; this is the diff.
---
## A. State of the App — what changed since v1.4.16
The 24h since the v1.4.16 memo have been a course-correction cycle.
Three concrete shifts:
**Charts walked back from "Apple-Health polish" to clean lines + opt-in
overlays.** v1.4.16 B1a put gradient area-fills under every line, emoji
glyphs on every mood data point, and an always-on personal-baseline
ReferenceLine. Marc looked at it on his phone and rejected all three.
v1.4.18 A3 (`agent/a3-charts-revert`, six commits) deleted the
`chart-gradient` module, replaced mood emoji with plain dots, gated the
baseline behind a Trend toggle, and added a per-chart settings-cog
popover with three independent switches (Trend / Trend-arrow /
Target-range), defaults all OFF. Persistence reuses the v1.4.16 B8
`User.dashboardWidgetsJson` JSON-blob pattern (no Prisma migration).
The wins from B1a that I kept — smooth interpolation, rich tooltips,
600 ms ease-out animation — are still in. **The lesson: the v1.4.16
"chart visual leap" headline overshot reality by about three rejected
features.** Net visual quality is now higher than v1.4.15 and lower
than v1.4.16's marketing claim — the right place to be.
**Achievements expanded from a feature into an engagement surface.**
v1.4.18 B1 grew the roster 38 → 59 (+15 public, +6 hidden Easter-eggs),
added an `applyDiscoveryFilter` so locked-without-data badges hide
themselves, and crucially: **hidden achievements never render their
strings to the DOM until unlocked** — the page-source-inspection
attack on Easter-eggs is closed. New unlock toast plays an 8-second
Sparkles celebration with a localized "you unlocked a hidden
achievement!" headline. This is the first feature where I would tell
another user "go play with it" rather than "here's the data view".
**The /admin/api-tokens scrollbar took 3 release cycles to fix
correctly.** v1.4.15 hid columns. v1.4.16 added a card-list at <md.
v1.4.18 A2's Playwright probe at Pixel-5 against PROD pinned the bar
to the **AdminShell mobile section strip** (13 entries, 1692 px
scrollWidth) — one component above the api-tokens card. Every
previous attempt fixed the wrong component. New `.no-scrollbar`
utility applied to AdminShell + SettingsShell mobile strips. **Lesson:
when a defect survives two fixes, the bug isn't where the user is
pointing.**
**A regression that survived v1.4.15 + v1.4.16 finally fixed.** The
BD-Zielbereich tile rendered "7T: —" / "30T: —" because `/api/analytics`
only computed a single 30-day `bpInTargetPct` and the dashboard tile
passed `avg7={null}`/`avg30={null}` — pure unfinished wire, not a
calc bug. v1.4.18 A1 added `computeBpInTargetWindows()`, surfaced
`bpInTargetPct7d`/`bpInTargetPct30d`, threaded both through.
**v1.4.17 hotfix exposed schema-migration fragility.** v1.4.16's
strict zod schema was great for new payloads; pre-existing cached
insight blobs in the v1.4.14 shape (`{changed, stable, drivers,
nextSteps}`) fell through `safeParse()` and crashed
`<InsightAdvisorCard>` with `Cannot read properties of undefined
(reading 'replace')`. Six hours after v1.4.16 went live. Hotfix added
an `isLegacyInsightPayload()` detector, a self-contained legacy-CTA
card with a regenerate button, and defense-in-depth in
`stripChartTokens()`. Pattern to remember: **strict schema migrations
need a legacy-payload-CTA path, not a crash.**
Cumulative trajectory v1.4.14 → v1.4.18: **quality bar is moving up,
unevenly.** v1.4.14 was Codex-OAuth saga end + admin split. v1.4.15
was features (achievements / tour / backups / integrations). v1.4.16
was the polish-leap (AI explainability + chart polish). v1.4.17 +
v1.4.18 are the **course-correct cycle** — the polish-leap shipped
faster than judgement could vet it, three of its visual decisions got
rejected, one of its schema decisions crashed. The ratio of "feature
ship" to "feature walked back" is now closer to honest.
---
## B. Biggest items shipped this run (v1.4.16 + v1.4.17 + v1.4.18)
The top 6 in priority order:
1. **AI explainability stack landed** (v1.4.16 B5a–e). Citations,
multi-provider fallback, per-rec rationale + mini-chart, deterministic
confidence score, feedback loop with admin aggregate. This is the
line between "demo chatbot" and "you can show this to a doctor".
2. **Chart visual identity reset** (v1.4.18 A3, commits inside
`agent/a3-charts-revert`). Clean line is the default; Trend / Arrow /
Target-range each opt-in, persisted per user per chart via
`User.dashboardWidgetsJson.chartOverlayPrefs`. The persistence
pattern is the more important deliverable than the revert itself.
3. **Achievements as an engagement surface** (v1.4.18 B1, commits
`75c74f1...e75ea75`). 59 total, 6 hidden, opaque DOM rendering for
undiscovered Easter-eggs, discovery-filter for relevance. First
feature I'd recommend to another user.
4. **Comparison overlay** (v1.4.16 B8). vs-last-month / vs-last-year
on every chart + tile + AI summary, off the same `bucketSeries()`
pipeline. The dedicated `/insights/compare` page is still pending.
5. **`/admin/api-tokens` mobile finally clean** (v1.4.18 A2,
`3e16074`). After two wrong fixes, a Playwright PROD probe pinned
the actual culprit to AdminShell. Same fix benefits SettingsShell.
6. **Regression cleanup wave** — BD-tile sub-values wired (v1.4.18 A1,
`23363ca`); legacy-insight-payload CTA instead of crash (v1.4.17,
`79bfa27`). These are the kind of small fixes that decide whether
I trust the app.
---
## C. Updated v1.5 roadmap
Lifting C.1–C.11 from the v1.4.16 review as the base. v1.4.17 +
v1.4.18 changed five things; everything else stands.
### What's still on the list (unchanged from v1.4.16 review)
C.1 Coolify image-digest auto-deploy — still 5min Marc-side toggle.
C.2 Native ARM runner matrix for `docker-publish` — still S effort.
C.4 Dedicated `/insights/compare` page — i18n keys still reserved.
C.5 AI streaming + prompt-version A/B + per-user prompt prefix.
C.6 Charts library decision — see update below.
C.7 iOS native API contract freeze + `/api/v1/` versioning + bulk
endpoint.
C.8 Security hardening sweep (passkey replay, idempotency-TTL,
audit-log retention).
C.9 Performance — Recharts deferred bundle / SSR insights /
read-replica.
C.10 Cross-device backup encryption + S3 push.
C.11 Integrations — Apple Health → Oura → Garmin (recommended order
unchanged).
### What's NEW for v1.5 after v1.4.17 + v1.4.18
#### C.12 — Per-user UI preference persistence as a platform pattern
**What.** v1.4.18 A3 introduced `User.dashboardWidgetsJson.chartOverlayPrefs`:
a JSON sub-blob per UI surface, stored on the existing widgets column,
hydrated client-side via `useChartOverlayPrefs`, persisted via a thin
`PUT /api/dashboard/chart-overlay-prefs` route. Migration-free. The
contract is small enough to extract.
**Apply to:** sidebar collapsed state (currently localStorage, lost on
device swap), dashboard tile order (currently fixed), per-section
default time-window (currently 30d everywhere), per-channel
notification preferences (currently all-or-nothing per category). All
five are the same shape: small typed JSON, owned per user, default-off,
opt-in surface.
**Effort.** S per surface, M for the extracted hook + route.
**Importance.** Quality-of-life multiplier. Low-risk, high-perceived-
polish.
**Dependencies.** None.
#### C.13 — Achievement engine as a "next-best-action" predicate engine
**What.** v1.4.18 B1's evaluators are pure predicates over user data
(`mood-streak-7`, `weekend-warrior`, `consistent-month`). The same
predicate engine could drive **next-best-action suggestions on the
dashboard**: "you're 1 day from `mood-streak-7` — log today's mood",
"you have 47 BP readings, 3 from `bp-100`". The dashboard becomes
self-explanatory for new users.
**Effort.** M (new `<NextActionCard>` + reuse evaluator engine).
**Importance.** Differentiator. Apple Health / Withings show stats
but don't tell you what to do next; this would.
**Dependencies.** B1 evaluators (shipped).
#### C.14 — Hidden-achievement pattern as "discover features" tutorial
**What.** v1.4.18 B1's hidden Easter-egg pattern (locked-but-visible
opaque card, real strings hidden from DOM, unlock celebration) is a
generic "guided discovery" surface. Apply to **product features**: an
opaque "Connect a wearable" card unlocks Withings + moodLog + (v1.5)
Apple Health setup; "Set up automation" unlocks notification rules;
"Export for your doctor" unlocks the doctor-report. New users see
locked tiles, get a hint per category, and discover the surface area.
**Effort.** M (extract the opaque-card primitive + author 8–10 feature
"achievements").
**Importance.** Differentiator + replaces the v1.4.15 onboarding tour
with something stickier (one-shot tour vs. always-visible card grid).
**Dependencies.** B1 hidden pattern (shipped).
#### C.15 — Engagement loop on top of the achievement surface
**What.** Three layered features off B1:
- **Streak repair tokens.** A user breaks a 30-day streak by traveling.
Award one repair token per quarter that retroactively closes a single
gap. Soft loyalty.
- **Monthly digest emails.** End-of-month: "you unlocked 3 achievements,
hit `consistent-month`, your BP improved 4 mmHg vs. last month".
Reuses v1.4.16 B8 comparison + `RecommendationFeedback` aggregator
shape. Notification-channel "email" is the only new piece.
- **Friend-only leaderboards.** Opt-in, no global ranking, no
comparison without explicit consent. Two friends who both opt-in see
each other's streak counts and unlock-rate. No public leaderboard,
ever.
**Effort.** S/M/M.
**Importance.** Differentiator (combined). The market has tracker apps
and "social fitness" apps; nothing in between done with privacy as a
hard rule.
**Dependencies.** B1 (shipped); email channel needs adding to
`src/lib/notifications/dispatcher.ts` (~1 day).
#### C.16 — Admin/Settings shell layout audit
**What.** A2 took three release cycles to fix because each fix targeted
the wrong layer (api-tokens table → mobile card-list → finally
AdminShell strip). The broader admin/settings shell layout has issues
the per-component fixes won't catch. Run a focused audit pass across
**all 13 admin sections + 10 settings sections** at three viewports
(Pixel-5, iPad portrait, desktop) with Playwright + scrollWidth
diffing. Catalog every overflow. Fix the shell, not the leaves.
**Effort.** S–M (probe + fix wave).
**Importance.** Quality. The shell is the picture frame; today the
frame is bent in places.
**Dependencies.** None.
### What's REMOVED from v1.5 candidate list
Nothing. v1.4.16's deferred items (Coolify image-digest, native ARM
runner, prompt-tuning ratchet, `/insights/compare` page) all still
pending, all still good v1.5 candidates.
### Updated 6-week focus pick
v1.4.16's pick: **C.1 + C.2 + C.4 + C.7 + C.10 + C.11a Apple Health**.
After v1.4.18, I'd add **C.12 (preference persistence pattern)** as a
running side-quest — the wins are too cheap to skip and they build the
muscle for C.7's iOS contract. Drop nothing from the original pick.
Total effort still ~6 weeks because C.12 is parallelisable with C.7
(both are JSON-blob persistence shape decisions).
---
## D. Follow-on initiatives that build on v1.4.18 specifically
Three candidates rooted in v1.4.18 that I want on the v1.6+ shortlist:
1. **Hidden-achievement pattern → "discover features" tutorial surface
(C.14 above, also belongs here).** The opaque-card + unlock-on-trigger
primitive is the right shape for product discovery. Today new users
meet a tour-then-empty-dashboard experience; an "unlocked surface
area" pattern keeps surface area visible without overwhelming.
_Why this is a follow-on, not a v1.5 must-have:_ it works only when
the surface area is large enough to warrant discovery, which Apple
Health + Garmin + Oura integrations (C.11) provide. Land C.11 first,
then layer C.14.
2. **Per-chart toggle prefs as a template for ALL UI personalization
(overlap with C.12).** The settings-cog popover on every chart in
A3 is the right interaction model for any "I want this surface my
way" affordance — sidebar-collapsed, dashboard-tile-order, dashboard-
tile-time-window, notification-channel-per-event. The per-chart
pattern is the prototype; v1.6 generalises it to a `<UISettings>`
primitive that any client component can drop in. **What this unlocks:**
user-configurable everything without a dedicated `/settings`
page.
3. **Achievement evaluators as a reusable predicate engine for
next-best-action suggestions (C.13 above).** The evaluators are
already pure functions over `(user, data) → boolean | progress`.
Adapt to "what are the 3 closest unlocks for this user" — surface as
dashboard nudges. **What this unlocks:** the dashboard becomes
self-narrating ("you're 1 BP reading away from `bp-50`") without
needing the AI insights pipeline at all. Local, instant, no LLM
call. Pairs cleanly with the v1.6 candidate "rationale-driven
on-device predictions" from the v1.4.16 review §D.6.
---
## E. Risks / Tech-Debt watchlist
1. **v1.4.18 reverted v1.4.16's chart polish — what's the accumulating
risk?** Three visual decisions (gradients, emoji, auto-mean) shipped,
were rejected, were reverted. The deeper risk: in v1.5, somebody
(me, or a design-review agent) is going to propose those exact
things again under different framing — "subtle area fill",
"expressive mood scale", "smart baseline". The `feedback_charts_visual_identity.md`
memory is the guardrail; **add a CLAUDE.md rule** in v1.5 that
chart visual changes need explicit Marc sign-off, not just
design-review approval.
2. **`.no-scrollbar` utility risk.** A2 added a generic `no-scrollbar`
class and applied it to two shells. The utility doesn't say
"horizontal-strip only" — somebody will reach for it on a vertical
list and hide a legitimate overflow scrollbar. Either rename to
`.no-scrollbar-horizontal` + assert it's only on `overflow-x` parents,
or audit usage on every PR. Cheap rename; do it before another
shell adopts it.
3. **Strict-schema legacy-payload pattern.** v1.4.17 hotfix exposed
that v1.4.16's strict insight schema didn't gracefully handle
pre-strict cached payloads. This same shape exists in three other
places: cached AI insights (just fixed), cached achievement summary
shape (v1.4.16 changed it), and the comparison-overlay payload
(v1.4.16 introduced it). **Audit every `safeParse()` call in
`src/lib/`** and confirm there's a legacy-payload CTA path, not a
silent crash. Not v1.5 work — a v1.4.19 cleanup pass.
4. **Achievement evaluator cost per dashboard load.** With 59
achievements + discovery filter + summary recompute + iOS-format
filter, evaluation runs on every `GET /api/achievements`. Each
evaluator does its own DB query (mood entries, measurements, audit
rows). **Profile after deploy:** if p95 over 200ms on Marc's
account, batch-fetch + memoize. The 59-row scale is fine; the 100+
roster I might add in v1.5 is not.
5. **`/admin/api-tokens` story isn't done.** A2 fixed the shell
scrollbar; the table itself still has 7 columns and looks dense at
`<lg`. A future viewport (foldable, narrow tablet) might surface
the same complaint about the card list's button row. Worth
spending a focused sweep (C.16 admin shell audit) before another
user complains.
6. **Hidden-achievement DOM-leak attack surface needs lock-in.** B1
asserts the test for "real strings absent from DOM" but if a future
contributor adds `aria-label={achievement.title}` to an icon, the
leak is silent. Add an ESLint rule or a Playwright assertion that
greps the rendered page-source for any `ACHIEVEMENT_DEFINITIONS`
string when the user hasn't unlocked it. Defense in depth; the
existing test is necessary but not sufficient.
---
## F. Candid one-liner
HealthLog took 36 hours to ship a polish-leap, walk back three
overshoots, fix a regression that survived two earlier fixes, and patch
a crash six hours after a release — and came out of it with a higher
quality bar, a real engagement surface, and a clearer sense of which
patterns are reusable; v1.5 is now the release where those patterns
either cohere into a product or stay isolated wins.
+46
View File
@@ -0,0 +1,46 @@
# Phase E1 — v1.4.16 verify + tag
Timestamp: 2026-05-10T03:40+02:00
## Verification
| Step | Result | Notes |
| ----------------------- | ------ | -------------------------------------------- |
| `pnpm typecheck` | PASS | 0 errors |
| `pnpm lint` | PASS | 0 errors / 12 pre-existing warnings |
| `pnpm format:check` | SKIP | not in CI per project convention; running |
| | | `pnpm format` corrupts `.planning/` markdown |
| | | list-markers (already observed in Phase 0) |
| `pnpm test` | PASS | 1540 / 1540 unit tests, 192 files, 4.29s |
| `pnpm test:integration` | PASS | 59 / 59 integration tests, 16 files, 8.35s |
`pnpm build` and `pnpm e2e` deferred to CI Docker (Node 22) per
CLAUDE.md.
## Release artefacts
- **Bumped** `package.json` version `1.4.15` → `1.4.16`.
- **CHANGELOG entry** added for `## [1.4.16] — 2026-05-09` (English-
only per Marc's "alles Englisch" note; sections: Added / Changed /
Fixed / Performance / Security / Internal / Deferred to v1.5).
- **Release commit**: `d443c22 chore(release): v1.4.16` — package.json
- CHANGELOG.md only (4 untracked stale dotted-segment export route
directories left untracked; the live plain-segment routes ship in
the previous commits).
- **Tag**: `v1.4.16` (annotated, message `HealthLog v1.4.16`).
- **Push**: `5e89382..d443c22 main -> main` and `[new tag] v1.4.16`
to `git@github.com:MBombeck/HealthLog.git`.
## CI
`gh run list --limit 5` immediately after push returned five
in-progress runs at `2026-05-10T01:40:03Z–01:40:07Z`:
- **GHCR (tag)**: run id `25616783583` — Build & Publish Docker Image
on `v1.4.16`.
- GHCR (main): run id `25616782255`.
- Security & Quality (main): run id `25616782285`.
- Integration tests (main): run id `25616782236`.
- e2e (main): run id `25616782233`.
Phase E2 picks up from here.
+104
View File
@@ -0,0 +1,104 @@
# Phase E2 — v1.4.16 deploy + verify
Timestamp: 2026-05-10T03:48+02:00
## GHCR runs
Both runs `success` — Wave-C C3 fix (drop arm64 from main-branch publish,
qemu-SIGILL root cause) was effective. Main-branch GHCR no longer hangs
the way it did on v1.4.14 / v1.4.15.
| Run | Branch | Conclusion | Notes |
| ----------- | --------- | ---------- | ------------------------------------------------- |
| 25616783583 | `v1.4.16` | success | tag build, pushes `:1.4.16` |
| 25616782255 | `main` | success | main build, pushes `:latest` (amd64-only post-C3) |
The two runs published DIFFERENT digests, however — `:latest` resolved to
`sha256:4841bef396ad…` (main build) while `:1.4.16` resolved to
`sha256:2f1a0d6b381d…` (tag build). They diverge because they ran on
different commits / build invocations even though both are at
`d443c22`. Coolify's git-push trigger fired on the chore(release)
commit and recreated the container at `01:40:17` UTC — but at that
moment GHCR's main run was still in_progress, so Coolify pulled the
host's stale local `:latest` cache (`ace7d441f47b…`, the v1.4.15
image). Auto-deploy did NOT successfully advance to v1.4.16.
## Image digest
| Phase | Digest |
| ------------ | ----------------------------------------------------------------------------------- |
| BEFORE | `sha256:ace7d441f47bd8c69fd0c5e2417b7f6c53bc387aa10c9aa541ad5e6321e9581d` (v1.4.15) |
| AFTER (live) | `sha256:05f8a126d63962d9a4af4769de830d3fee022d634787e811b4339ee464420daa` (v1.4.16) |
Digest visibly changed. Container `app-pg8wggwogo8c4gc4ks0kk4ss-014013348914`
re-recreated at `01:45:5x` UTC after the host-side retag.
## Coolify auto-deploy: NO — retag-on-host fallback was needed
Same v1.4.14 / v1.4.15 race pattern: Coolify recreated on chore(release)
push BEFORE GHCR finished, so it pulled the stale local `:latest` cache.
Ran the documented fallback recipe:
```
ssh apps-01 'docker pull ghcr.io/mbombeck/healthlog:1.4.16 && \
docker tag ghcr.io/mbombeck/healthlog:1.4.16 ghcr.io/mbombeck/healthlog:latest && \
cd /data/coolify/applications/pg8wggwogo8c4gc4ks0kk4ss && \
docker compose up -d app'
```
## /api/version transition
- Before recreate: `1.4.15`
- After recreate: `1.4.16` — first 200 at `2026-05-10T03:45:58+02:00`
(`01:45:58Z`), 1 s after the final retag/up command returned. Cap was
5 min; flipped instantly.
## Production smoke (curl, 14 routes, Marc's session)
| Path | Status |
| ------------------------- | ------ |
| `/` | 200 |
| `/dashboard` | 404 \* |
| `/insights` | 200 |
| `/auth/login` | 200 |
| `/settings/integrations` | 200 |
| `/settings/notifications` | 200 |
| `/settings/ai` | 200 |
| `/settings/export` | 200 |
| `/admin` | 200 |
| `/admin/users` | 200 |
| `/admin/backups` | 200 |
| `/admin/system-status` | 200 |
| `/admin/app-logs` | 200 |
| `/achievements` | 200 |
\* `/dashboard` 404 is expected: HealthLog's dashboard lives at `/`
(home route, PWA convention) — there is no `/dashboard` route in the
App Router tree. `<title>HealthLog</title>` confirmed at `/`. NOT a
regression — same response shape on v1.4.15.
## GH release
URL: https://github.com/MBombeck/HealthLog/releases/tag/v1.4.16
Created via `gh release create v1.4.16 --notes-file /tmp/release-v1.4.16.md
--title "HealthLog v1.4.16" --verify-tag`. Notes extracted via
`sed -n '3,171p' CHANGELOG.md` (the awk recipe in the brief had a
precedence issue and only captured the version header line; sed
range-extract from `## [1.4.16]` (line 3) to the line before
`## [1.4.15]` (line 172) gave the full block, last line
`comparison.insightsCallout.{lastMonth,lastYear}` reserved).
## Final sanity
- `/api/version` = `1.4.16` (production, post-deploy).
- `gh release view v1.4.16` returns the release URL.
- Container healthy 4 min after recreate at write time.
## Outstanding items for v1.5
- Coolify auto-deploy race condition (Marc-side UI flip; plan in
`docs/audit/v1416-auto-deploy-fix.md`) — still relevant; same race
manifested on this release.
- arm64 reinstatement on `docker-publish.yml` via native
`ubuntu-24.04-arm` matrix (currently amd64-only post-C3).
+74
View File
@@ -0,0 +1,74 @@
# Phase E3 — Docs + Landing Sync (v1.4.16)
Completed 2026-05-10 ~03:55 CEST. Two repos touched, three commits, both pushed.
## healthlog-docs (commits on origin/main)
- `8addef4 chore(release): document v1.4.16 highlights` — 9 existing pages
refreshed.
- `features/ai-insights.mdx` — five new sections (per-recommendation
explainability, confidence score, was-this-helpful feedback,
medical-reference grounding, multi-provider fallback chain). 10/h
rate-limit + cache-evict-on-regen documented.
- `features/dashboard-customization.mdx` — Apple-Health-style chart
polish (gradient + animation + personal baseline + rich tooltips +
explicit empty state) and the comparison-overlay toggle. 7-day
trend label normalisation called out.
- `features/health-metrics.mdx` — comparison-overlay link added to
the trend-analysis section.
- `features/export-import.mdx` — new Settings → Export menu (5
cards, audit-log entry per export, shared 10/h bucket).
- `features/doctor-report.mdx` — entry-point relocated under
Settings → Export.
- `features/gamification.mdx` — dashboard preview now shares the
achievements-page data source.
- `configuration/admin-settings.mdx` — host-load chart, app-log
preview, AI-quality preview, audit-log filtering + CSV export, and
new section table rows for /admin/app-logs, /admin/audit-log,
/admin/ai-quality.
- `self-hosting/updates.mdx` + `self-hosting/scaling.mdx` — image
tag bumped 1.4.15 → 1.4.16.
- `2a5802b docs: add deep-dive pages for AI insights, comparison
views, and provider config` — three new long-form pages plus
Starlight sidebar wiring.
- `insights/how-it-works.mdx` — end-to-end walk-through of the
recommendation card, the deterministic confidence computation,
fallback-chain hard-fail policy, what "low confidence" means, how
feedback is used today + planned, and per-user privacy.
- `dashboard/comparison.mdx` — toggle UX, forward-shift overlay
semantics, what vs.-last-month / vs.-last-year actually compute,
sparse-data handling, and deferred follow-ups.
- `settings/ai-providers.mdx` — setup paths for all five providers
(Codex / OpenAI direct / Anthropic / local / admin-shared), the
fallback-chain editor, test-connection button, credential storage
table.
- Astro build green: 44 pages built, all three new routes generated
(`/insights/how-it-works/`, `/dashboard/comparison/`,
`/settings/ai-providers/`), pagefind index green.
- Push: `db66da0..2a5802b main -> main` to
`github.com:MBombeck/healthlog-docs.git`.
## healthlog-landing (commit on origin/main)
- `3d17207 chore(release): v1.4.16 version label + AI insights
mention` — `softwareVersion` bumped 1.4.15 → 1.4.16; JSON-LD
feature list gains 5 new entries (multi-provider fallback,
explainability, confidence score, medical-reference grounding,
chart polish, comparison overlays); hero AI-insights card copy
rewritten as "AI insights that show their work" leading with
explainability + confidence + citations; capability-badges row
picks up two new badges. `pnpm build` green.
- Push: `b6f83be..3d17207 main -> main` to
`github.com:MBombeck/healthlog-landing.git`.
## Main HealthLog repo
Untouched per scope (this phase reads only).
## Notes
- No "Claude / AI / agent / marathon / phase" leak in any docs or
landing copy.
- No --no-verify / --no-gpg-sign — neither repo has pre-commit hooks
configured today, so nothing was bypassed.
- Co-Author trailer present on all three commits.
+3 -3
View File
@@ -7,7 +7,7 @@ Full document: `.planning/v1416-research-ai-recommendations.md`
## What ran
1. STATE.md elevation — replaced the single `B5 — AI hallucination-
hardening v2` bucket with five first-class items (B5a medical-
hardening v2` bucket with five first-class items (B5a medical-
reference grounding, B5b multi-provider redundancy, B5c per-rec
explainability, B5d confidence score, B5e user-feedback loop) and
added a new `B8 — Extended comparison views (Vormonat / Vorjahr)`.
@@ -31,7 +31,7 @@ Full document: `.planning/v1416-research-ai-recommendations.md`
score expands into per-component cards with band + value + trend.
HealthLog's strict schema already carries `metricSource`; the
incremental cost of adding `rationale: { dataWindow, comparedTo,
deviation }` is one zod block + one prompt-version bump. The UI
deviation }` is one zod block + one prompt-version bump. The UI
slot lives inside an already-planned `<RecommendationCard>` and
the data source (bucketed daily/monthly history per metric) is
already produced by `bucket-series.ts`.
@@ -67,7 +67,7 @@ cohesive slice that delivers user-visible explainability + confidence.
1. `chore(planning): elevate B5c/d/e + B8 for v1.4.16` — STATE+ROADMAP.
2. `docs(planning): v1.4.16 ecosystem research for explainability +
confidence + feedback + comparison views` — research doc + this
confidence + feedback + comparison views` — research doc + this
summary.
Both pushed to origin/main.
+13 -14
View File
@@ -37,7 +37,7 @@ reached the polished `<InsightAdvisorCard>` (newly mounted on
```ts
export function stripChartTokens(text: string): string {
return text
.replace(TOKEN_REGEX, "") // ← crash here when text === undefined
.replace(TOKEN_REGEX, "") // ← crash here when text === undefined
.replace(/\s{2,}/g, " ")
.trim();
}
@@ -60,18 +60,18 @@ v1.4.14 shape.
- **Safe (string-typed input or runtime-string)**: 81 hits. All
`t(...).replace(...)` (i18n returns string), `.toISOString()
.replace(...)`, `.toLowerCase().replace(...)`, server-side string
.replace(...)`, `.toLowerCase().replace(...)`, server-side string
columns from Prisma (NOT NULL).
- **Crash-on-undefined**: 1 — the production bug. No analogous
fragile patterns elsewhere.
Severity:
| Class | Count |
|---|---|
| Production crash sites found | 1 |
| Already-guarded patterns worth strengthening | 0 |
| New crash-on-undefined risks elsewhere | 0 after this hotfix |
| Class | Count |
| -------------------------------------------- | ------------------- |
| Production crash sites found | 1 |
| Already-guarded patterns worth strengthening | 0 |
| New crash-on-undefined risks elsewhere | 0 after this hotfix |
Detailed audit: `.planning/v1417-replace-audit.md`.
@@ -90,8 +90,8 @@ Detailed audit: `.planning/v1417-replace-audit.md`.
3. **`src/components/insights/insight-advisor-card.tsx`** — added an
`isUnrenderable` short-circuit (`typeof insight.summary !==
"string" || !Array.isArray(insight.findings) ||
!Array.isArray(insight.recommendations)`) before the rich-card
"string" || !Array.isArray(insight.findings) ||
!Array.isArray(insight.recommendations)`) before the rich-card
render path. The legacy / malformed blob now hits a self-contained
regenerate-CTA card instead of trying to render the v1.4.14 shape.
Belt-and-suspenders alongside the `legacyPayload` flag.
@@ -114,11 +114,11 @@ properties of undefined (reading 'replace')` error). GREEN after.
## Commits
| Commit | Message |
|---|---|
| Commit | Message |
| --------- | -------------------------------------------------------------------------------- |
| `79bfa27` | `fix(insights): handle legacy cached payload without rationale (regenerate CTA)` |
| `adab80a` | `chore(release): v1.4.17` |
| `da7070e` | `style(insights): prettier sweep on legacy-payload hotfix files` |
| `adab80a` | `chore(release): v1.4.17` |
| `da7070e` | `style(insights): prettier sweep on legacy-payload hotfix files` |
Tag `v1.4.17` pushed.
@@ -159,4 +159,3 @@ step.
- **GH release**: https://github.com/MBombeck/HealthLog/releases/tag/v1.4.17
Status: **live**.
+7 -7
View File
@@ -6,13 +6,13 @@ Wave B has since advanced to `8d9f864` mid-run).
## CI status (last completed run per workflow on `94c748d`)
| Workflow | Conclusion | Notes |
| ------------------------- | ---------- | ------------------------------------------------------------------------ |
| Security & Quality | success | typecheck + lint + unit tests all green |
| Integration tests | failure | pre-existing, started 2026-05-09T18:47:14Z (commit `d8c549e`); 50+ runs failing |
| e2e | failure | pre-existing, see `.github/workflows/e2e.yml`; 25 failures + 74 cancellations in last 100 |
| Build & Publish Docker | cancelled | superseded by next push — normal during marathon push cadence |
| Post-publish verify | skipped | gated on Build success |
| Workflow | Conclusion | Notes |
| ---------------------- | ---------- | ----------------------------------------------------------------------------------------- |
| Security & Quality | success | typecheck + lint + unit tests all green |
| Integration tests | failure | pre-existing, started 2026-05-09T18:47:14Z (commit `d8c549e`); 50+ runs failing |
| e2e | failure | pre-existing, see `.github/workflows/e2e.yml`; 25 failures + 74 cancellations in last 100 |
| Build & Publish Docker | cancelled | superseded by next push — normal during marathon push cadence |
| Post-publish verify | skipped | gated on Build success |
Both failing workflows predate Wave A — `0985c93` (v1.4.15 release) shows
the identical 6-test integration failure shape and an e2e failure at the
+11 -11
View File
@@ -41,12 +41,12 @@ agents.
- **H1 senior** — `src/app/page.tsx` (1031 LOC) — split
`<DashboardShell>` + `<DashboardTileStrip>` + `<DashboardChartGrid>`
+ `<DashboardEmptyState>`; pull visibility resolver into
`src/lib/dashboard-visibility.ts`. Mechanical.
- `<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
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
@@ -81,7 +81,7 @@ agents.
`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
151-152` — anchor date range to Europe/Berlin explicitly OR document
the off-by-one boundary risk.
### security
@@ -127,16 +127,16 @@ agents.
— 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.
- `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.
- `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
@@ -167,7 +167,7 @@ agents.
### code-review
- **L1** — `src/components/onboarding/tour.tsx:188` — `TOOLTIP_HEIGHT
= 220` fixed estimate; measure rendered card via ResizeObserver. (H2
= 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).
@@ -229,7 +229,7 @@ agents.
`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`
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
+67 -67
View File
@@ -52,8 +52,8 @@ deviations against a learned per-user baseline rather than a single
fixed threshold. The Vitals app on watchOS 11 is even more direct:
each metric is shown in-band vs. out-of-band relative to the user's
"typical range," and tapping into a metric reveals which nights
contributed the typical-range envelope (Apple Support, *View your
health data*; Century, *Apple Watch Vitals app explained*). The
contributed the typical-range envelope (Apple Support, _View your
health data_; Century, _Apple Watch Vitals app explained_). The
mental model that translates: "this finding is interesting because it
deviates from **your** norm" is visible everywhere — there is no
one-shot population claim without a personal baseline.
@@ -61,16 +61,16 @@ one-shot population claim without a personal baseline.
**Withings Health Mate.** Health Insights surface ad-hoc nudges with
a science-based interpretation. They link out to a long-form
explainer page rather than expanding inline; user reviews repeatedly
complain about *opacity* — the insight is a one-liner without a
complain about _opacity_ — the insight is a one-liner without a
visible data-window or threshold (justuseapp reviews, Withings
Support, *What is Health Insights*). HealthLog should NOT copy the
Support, _What is Health Insights_). HealthLog should NOT copy the
out-link pattern; explanations belong inline.
**Oura Ring.** This is the clearest reference point. Both Sleep and
Readiness scores expand into **Contributors** — a per-score breakdown
of (e.g.) Total Sleep, Efficiency, Restfulness, REM, Deep, Latency,
Timing — each shown with its own band, current value, and personal
trend (ouraring.com, *Sleep Contributors*, *Readiness Contributors*).
trend (ouraring.com, _Sleep Contributors_, _Readiness Contributors_).
Tapping a contributor reveals "what's pulling this lower." This is
the explainability gold standard for consumer wellness UI.
@@ -87,15 +87,15 @@ the explainability gold standard for consumer wellness UI.
### 1B. Existing architecture fit
| Concern | Touched files / what changes |
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Schema | `src/lib/ai/schema.ts` — `aiRecommendationSchema` extended with required `rationale` object (`dataWindow`, `comparedTo`, `deviation`) and optional `referenceId` (filled by B5a). |
| Prompt | `src/lib/ai/prompts/insight-generator.ts` — `PROMPT_VERSION` bumped to `4.16.0`; output-format block adds the rationale shape; ground rule "every rec MUST carry a rationale" added under §GROUND RULES. |
| Wrapper | `src/lib/ai/generate-insight.ts` — corrective retry message lists the rationale fields when missing; `findUncitedRecommendations()` companion `findRecommendationsMissingRationale()` added. |
| UI | `src/components/insights/insight-advisor-card.tsx` — new `<RecommendationCard>` subcomponent with expand-arrow → `<RationaleCard>` (3-row data + `<HealthChart>` mini-chart for the window). `<InsightsCard>` likewise gets the same expand affordance. |
| Charts | `src/components/charts/health-chart.tsx` — accepts a `windowOverride` prop so the rationale's mini-chart can pin to e.g. last7days regardless of the parent's chosen range. |
| i18n | New keys under `insights.recommendation.*`: `rationale`, `rationaleWindow`, `rationaleComparedTo`, `rationaleDeviation`, `rationaleExpand`, `rationaleCollapse`. Both `messages/en.json` + `messages/de.json`. |
| Auth + audit | No new auth surface; the rec card consumes existing `requireAuth()`-gated insight payloads. No new audit row — explanations are read-only client-side derivations from the cached payload. |
| Concern | Touched files / what changes |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Schema | `src/lib/ai/schema.ts` — `aiRecommendationSchema` extended with required `rationale` object (`dataWindow`, `comparedTo`, `deviation`) and optional `referenceId` (filled by B5a). |
| Prompt | `src/lib/ai/prompts/insight-generator.ts` — `PROMPT_VERSION` bumped to `4.16.0`; output-format block adds the rationale shape; ground rule "every rec MUST carry a rationale" added under §GROUND RULES. |
| Wrapper | `src/lib/ai/generate-insight.ts` — corrective retry message lists the rationale fields when missing; `findUncitedRecommendations()` companion `findRecommendationsMissingRationale()` added. |
| UI | `src/components/insights/insight-advisor-card.tsx` — new `<RecommendationCard>` subcomponent with expand-arrow → `<RationaleCard>` (3-row data + `<HealthChart>` mini-chart for the window). `<InsightsCard>` likewise gets the same expand affordance. |
| Charts | `src/components/charts/health-chart.tsx` — accepts a `windowOverride` prop so the rationale's mini-chart can pin to e.g. last7days regardless of the parent's chosen range. |
| i18n | New keys under `insights.recommendation.*`: `rationale`, `rationaleWindow`, `rationaleComparedTo`, `rationaleDeviation`, `rationaleExpand`, `rationaleCollapse`. Both `messages/en.json` + `messages/de.json`. |
| Auth + audit | No new auth surface; the rec card consumes existing `requireAuth()`-gated insight payloads. No new audit row — explanations are read-only client-side derivations from the cached payload. |
### 1C. Proposed implementation plan (file-level)
@@ -188,7 +188,7 @@ Attention) plus a numeric value, plus a "what's contributing"
breakdown. They don't expose calibrated probabilities; they expose
**states + thresholds** that map directly onto a colour. This is a
better reference than raw probabilities for non-clinicians
(ouraring.com, *Sleep Score*, *Readiness Score*).
(ouraring.com, _Sleep Score_, _Readiness Score_).
**Top 3 patterns to borrow.**
@@ -206,15 +206,15 @@ better reference than raw probabilities for non-clinicians
### 2B. Existing architecture fit
| Concern | Touched files / what changes |
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Schema | `src/lib/ai/schema.ts` — `aiRecommendationSchema` gains required `confidence: z.number().int().min(0).max(100)` once B5c lands. Pre-B5c rollout: `.optional()` then flip to required in same milestone. |
| Confidence calc | `src/lib/ai/confidence.ts` (NEW) — pure function `computeConfidence({ n, recencyDays, deviationStdRatio })` returning 0-100. Ranged inputs from `metricSource.n`, the user's `bucketSeries()` payload's freshest bucket, and the deviation magnitude. |
| Prompt | The model proposes a confidence; the wrapper **OVERRIDES** with the deterministic `computeConfidence()`. Two reasons: (1) calibrated probabilities are not a small-LLM strength; (2) deterministic reproducibility for the v1.4.17 feedback ratchet. |
| Wrapper | `src/lib/ai/generate-insight.ts` post-validation step injects `confidence` from `computeConfidence()` before returning to caller — model's value is discarded. |
| UI | `<RecommendationCard>` (from B5c) renders a `<ConfidenceMeter>`: 5-bar SVG that maps 0-20 / 20-40 / 40-60 / 60-80 / 80-100 to 1-5 lit bars. Below 25, lit-bar count is 1 + draft pill. Below 50 carries the "based on limited data" caption. |
| i18n | `insights.recommendation.confidence`, `insights.recommendation.confidenceLow`, `insights.recommendation.confidenceDraft`. EN+DE. |
| Auth + audit | None. Confidence is derived; nothing to authenticate, nothing to audit. |
| Concern | Touched files / what changes |
| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Schema | `src/lib/ai/schema.ts` — `aiRecommendationSchema` gains required `confidence: z.number().int().min(0).max(100)` once B5c lands. Pre-B5c rollout: `.optional()` then flip to required in same milestone. |
| Confidence calc | `src/lib/ai/confidence.ts` (NEW) — pure function `computeConfidence({ n, recencyDays, deviationStdRatio })` returning 0-100. Ranged inputs from `metricSource.n`, the user's `bucketSeries()` payload's freshest bucket, and the deviation magnitude. |
| Prompt | The model proposes a confidence; the wrapper **OVERRIDES** with the deterministic `computeConfidence()`. Two reasons: (1) calibrated probabilities are not a small-LLM strength; (2) deterministic reproducibility for the v1.4.17 feedback ratchet. |
| Wrapper | `src/lib/ai/generate-insight.ts` post-validation step injects `confidence` from `computeConfidence()` before returning to caller — model's value is discarded. |
| UI | `<RecommendationCard>` (from B5c) renders a `<ConfidenceMeter>`: 5-bar SVG that maps 0-20 / 20-40 / 40-60 / 60-80 / 80-100 to 1-5 lit bars. Below 25, lit-bar count is 1 + draft pill. Below 50 carries the "based on limited data" caption. |
| i18n | `insights.recommendation.confidence`, `insights.recommendation.confidenceLow`, `insights.recommendation.confidenceDraft`. EN+DE. |
| Auth + audit | None. Confidence is derived; nothing to authenticate, nothing to audit. |
### 2C. Proposed implementation plan (file-level)
@@ -287,11 +287,11 @@ star reviews are the only loop, and they don't tie back to the rec.
**Oura Ring.** Has a **Tags** feature where users mark daily
behaviours (caffeine, alcohol, late meal, etc.) and Oura correlates
those with sleep score deviations (Oura Help, *Sleep Score*). This
those with sleep score deviations (Oura Help, _Sleep Score_). This
isn't a thumbs-up on a rec — it's a much richer behavioural signal
that funds Oura's correlations. HealthLog can't copy verbatim
because the user base is too small for cross-user training, but the
*pattern of asking the user to label cause-effect over time* is
_pattern of asking the user to label cause-effect over time_ is
worth borrowing in v1.5+ as an opt-in.
**Top 3 patterns to borrow.**
@@ -310,15 +310,15 @@ worth borrowing in v1.5+ as an opt-in.
### 3B. Existing architecture fit
| Concern | Touched files / what changes |
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Schema | NEW Prisma model `RecommendationFeedback` (replaces the v1.4.17 roadmap's `InsightFeedback` proposal — same idea, narrower scope). Includes `userId`, `recommendationId`, `recommendationText`, `recommendationSeverity`, `helpful`, `providerType`, `promptVersion`, `createdAt`. |
| API | NEW `POST /api/insights/feedback` — accepts `{ recommendationId, recommendationText, helpful }`, fills `providerType` + `promptVersion` server-side from the cached payload. `requireAuth()`-gated. Idempotent via `Idempotency-Key`. |
| UI | `<RecommendationFeedback>` component — 2 small icon buttons under the rec, optimistic mutation via TanStack Query. Disabled when already submitted (read from local cache + a stable `(userId, recommendationId)` key). |
| Aggregation | NEW worker job `insightsFeedbackAggregator` (pg-boss) computes per-(severity × metricSource.type) helpful-rate weekly and writes to `AppSettings.adminAiInsightsFeedbackSummary` (small JSON blob). Source of truth for any future prompt-tuning step. |
| Prompt-tuning | DEFERRED to v1.4.17 — no prompt mutation in v1.4.16. The B5e milestone delivers the loop infrastructure; the ratchet itself is a follow-on. |
| i18n | `insights.recommendation.feedbackHelpful`, `insights.recommendation.feedbackNotHelpful`, `insights.recommendation.feedbackThanks`, `insights.recommendation.feedbackConfirmed`. EN+DE. |
| Auth + audit | `POST /api/insights/feedback` writes an audit row `insights.recommendation.feedback` with `{ helpful, severity, providerType, promptVersion }`. Per-user only; cross-user aggregate stays on the worker. |
| Concern | Touched files / what changes |
| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Schema | NEW Prisma model `RecommendationFeedback` (replaces the v1.4.17 roadmap's `InsightFeedback` proposal — same idea, narrower scope). Includes `userId`, `recommendationId`, `recommendationText`, `recommendationSeverity`, `helpful`, `providerType`, `promptVersion`, `createdAt`. |
| API | NEW `POST /api/insights/feedback` — accepts `{ recommendationId, recommendationText, helpful }`, fills `providerType` + `promptVersion` server-side from the cached payload. `requireAuth()`-gated. Idempotent via `Idempotency-Key`. |
| UI | `<RecommendationFeedback>` component — 2 small icon buttons under the rec, optimistic mutation via TanStack Query. Disabled when already submitted (read from local cache + a stable `(userId, recommendationId)` key). |
| Aggregation | NEW worker job `insightsFeedbackAggregator` (pg-boss) computes per-(severity × metricSource.type) helpful-rate weekly and writes to `AppSettings.adminAiInsightsFeedbackSummary` (small JSON blob). Source of truth for any future prompt-tuning step. |
| Prompt-tuning | DEFERRED to v1.4.17 — no prompt mutation in v1.4.16. The B5e milestone delivers the loop infrastructure; the ratchet itself is a follow-on. |
| i18n | `insights.recommendation.feedbackHelpful`, `insights.recommendation.feedbackNotHelpful`, `insights.recommendation.feedbackThanks`, `insights.recommendation.feedbackConfirmed`. EN+DE. |
| Auth + audit | `POST /api/insights/feedback` writes an audit row `insights.recommendation.feedback` with `{ helpful, severity, providerType, promptVersion }`. Per-user only; cross-user aggregate stays on the worker. |
### 3C. Proposed implementation plan (file-level)
@@ -410,7 +410,7 @@ pg-boss. Aggregates last-30-day feedback per
window** (e.g. 14 weeks) to **a longer baseline** (12-15 months);
when a delta crosses a threshold, a trend chip appears with a
sentence like "averaged more steps over the past 14 weeks" (Apple
Support, leancrew.com *Apple Health trends*). The narrative-text
Support, leancrew.com _Apple Health trends_). The narrative-text
layer is the value-add — the chart underneath is secondary.
**Withings Health Mate.** Limited side-by-side comparison; mostly
@@ -436,16 +436,16 @@ dimmed historical line under a current line — exactly Marc's spec.
### 4B. Existing architecture fit
| Concern | Touched files / what changes |
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Schema | NEW `User.compareBaselinePref` String? (default null = "none"). Values: `"none" \| "lastMonth" \| "lastYear"`. Or stash inside `dashboardWidgetsJson` as `{ compareBaseline: "lastMonth" }` to avoid a schema migration — preferred since the field is ephemeral. |
| Bucketing | `src/lib/insights/bucket-series.ts` — already returns 3-year history (daily 360d + monthly 24×30d). The comparison overlay reads the existing payload — no API change. |
| Charts | `src/components/charts/health-chart.tsx` — accepts a `compareBaseline?: "lastMonth" \| "lastYear"` prop; renders a dimmed `<Line>` for the shifted series alongside the current `<Line>`. `mood-chart.tsx`, `medication-compliance-chart.tsx` mirror. |
| Tiles | `src/components/dashboard/tile-strip.tsx` — adds a delta callout `Δ +5%` next to the current value when `compareBaseline !== "none"`. |
| Insights | `src/lib/ai/prompts/insight-generator.ts` — when comparison is active, the user-prompt's snapshot includes a `comparison: { baseline, deltaSummary }` field; the prompt's GROUND RULES gain "narrate the comparison if `comparison` is non-null." |
| UI control | `src/components/charts/compare-toggle.tsx` (NEW) — segmented control "None / Vormonat / Vorjahr"; mounted next to the existing range tabs (7d/30d/90d/all). Persisted via `useSettings()` hook. |
| i18n | `charts.compareNone`, `charts.compareLastMonth`, `charts.compareLastYear`, `charts.deltaUp`, `charts.deltaDown`, `charts.deltaUnchanged`. EN+DE. |
| Auth + audit | None — preference change is a localised setting; no audit row. Reading the comparison payload uses the existing `requireAuth()`-gated insight + measurements endpoints. |
| Concern | Touched files / what changes |
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Schema | NEW `User.compareBaselinePref` String? (default null = "none"). Values: `"none" \| "lastMonth" \| "lastYear"`. Or stash inside `dashboardWidgetsJson` as `{ compareBaseline: "lastMonth" }` to avoid a schema migration — preferred since the field is ephemeral. |
| Bucketing | `src/lib/insights/bucket-series.ts` — already returns 3-year history (daily 360d + monthly 24×30d). The comparison overlay reads the existing payload — no API change. |
| Charts | `src/components/charts/health-chart.tsx` — accepts a `compareBaseline?: "lastMonth" \| "lastYear"` prop; renders a dimmed `<Line>` for the shifted series alongside the current `<Line>`. `mood-chart.tsx`, `medication-compliance-chart.tsx` mirror. |
| Tiles | `src/components/dashboard/tile-strip.tsx` — adds a delta callout `Δ +5%` next to the current value when `compareBaseline !== "none"`. |
| Insights | `src/lib/ai/prompts/insight-generator.ts` — when comparison is active, the user-prompt's snapshot includes a `comparison: { baseline, deltaSummary }` field; the prompt's GROUND RULES gain "narrate the comparison if `comparison` is non-null." |
| UI control | `src/components/charts/compare-toggle.tsx` (NEW) — segmented control "None / Vormonat / Vorjahr"; mounted next to the existing range tabs (7d/30d/90d/all). Persisted via `useSettings()` hook. |
| i18n | `charts.compareNone`, `charts.compareLastMonth`, `charts.compareLastYear`, `charts.deltaUp`, `charts.deltaDown`, `charts.deltaUnchanged`. EN+DE. |
| Auth + audit | None — preference change is a localised setting; no audit row. Reading the comparison payload uses the existing `requireAuth()`-gated insight + measurements endpoints. |
### 4C. Proposed implementation plan (file-level)
@@ -559,7 +559,7 @@ Cheap insurance.
### 5.3 Comparison-views + Insights — narrative drift
**Risk.** When comparison is active and the AI summary mentions the
baseline, the rationale on each rec might cite a *different* window
baseline, the rationale on each rec might cite a _different_ window
than the comparison the user is staring at. Cognitive whiplash.
**Mitigation.** Two rules:
@@ -570,7 +570,7 @@ than the comparison the user is staring at. Cognitive whiplash.
baseline when possible.
2. The UI's `<RationaleCard>` renders a bracket "(matches active
comparison)" badge when `rec.rationale.dataWindow ===
comparison.baseline`, and a softer "(different window)" badge
comparison.baseline`, and a softer "(different window)" badge
when they diverge — with a one-tap action to switch the global
comparison to match.
@@ -601,14 +601,14 @@ freshest row per (user, rec-text) for trend analysis.
Wave-B sequence within v1.4.16:
| Order | Phase | Rationale |
| ----- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | B5a | Medical-reference grounding ships the `references[]` bundle + `referenceId` schema field that B5c will reuse. Land first — don't double-ship. |
| 2 | B5b | Multi-provider redundancy hardens the *substrate*. Future features attribute by provider; provider abstraction needs to be solid first. |
| 3 | B5c | Per-rec explainability **defines the rec card layout**. All later UI work plugs into its named slots. |
| 4 | B5d | Confidence score plugs into B5c's rec card. Schema migration deferred to JSON-blob path; only zod additions. |
| 5 | B5e | Feedback loop adds the bottom-of-card thumbs. Touches Prisma (RecommendationFeedback). Provider attribution from B5b is now stable. |
| 6 | B8 | Comparison views are mostly chart-side; they intersect insights via the `comparison` snapshot block. Land last so the prompt is fully shaped. |
| Order | Phase | Rationale |
| ----- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | B5a | Medical-reference grounding ships the `references[]` bundle + `referenceId` schema field that B5c will reuse. Land first — don't double-ship. |
| 2 | B5b | Multi-provider redundancy hardens the _substrate_. Future features attribute by provider; provider abstraction needs to be solid first. |
| 3 | B5c | Per-rec explainability **defines the rec card layout**. All later UI work plugs into its named slots. |
| 4 | B5d | Confidence score plugs into B5c's rec card. Schema migration deferred to JSON-blob path; only zod additions. |
| 5 | B5e | Feedback loop adds the bottom-of-card thumbs. Touches Prisma (RecommendationFeedback). Provider attribution from B5b is now stable. |
| 6 | B8 | Comparison views are mostly chart-side; they intersect insights via the `comparison` snapshot block. Land last so the prompt is fully shaped. |
Time-budget carve-out: **B5a + B5c + B5d** are the smallest cohesive
slice that delivers user-visible explainability and confidence; if
@@ -628,7 +628,7 @@ implementation.
2. **Feedback aggregation policy.** Marc's spec says "no cross-user
training, opt-in for any aggregation." Question: is the
*single-user* aggregator (only Marc's own ratings shape Marc's own
_single-user_ aggregator (only Marc's own ratings shape Marc's own
future prompt) acceptable as a default, with cross-user-aggregate
gated behind an admin-only opt-in? Or must even the single-user
loop be opt-in? **(default-on vs opt-in)**
@@ -650,27 +650,27 @@ implementation.
## 8. Sources
- Apple Support — *View your data in Health on iPhone* —
- Apple Support — _View your data in Health on iPhone_ —
https://support.apple.com/guide/iphone/view-your-health-data-iphe3d379c32/ios
- leancrew.com — *Apple Health trends* —
- leancrew.com — _Apple Health trends_ —
https://leancrew.com/all-this/2024/11/apple-health-trends/
- Apple Watch Vitals app explained —
https://www.centuryai.app/blog/apple-watch-vitals-app-explained
- Withings Support — *What is Health Insights* —
- Withings Support — _What is Health Insights_ —
https://support.withings.com/hc/en-us/articles/360038534893-Health-Mate-Android-App-What-is-Health-Insights-
- Withings Blog — *Get to know the Health Mate app* —
- Withings Blog — _Get to know the Health Mate app_ —
https://www-ext.withings.com/2021/02/04/why-is-health-mate-the-most-advanced-health-management-app/
- justuseapp — *Withings Health Mate Reviews* —
- justuseapp — _Withings Health Mate Reviews_ —
https://justuseapp.com/en/app/542701020/withings-health-mate/reviews
- Oura — *Sleep Score* —
- Oura — _Sleep Score_ —
https://support.ouraring.com/hc/en-us/articles/360025445574-Sleep-Score
- Oura — *Sleep Contributors* —
- Oura — _Sleep Contributors_ —
https://support.ouraring.com/hc/en-us/articles/360057792293-Sleep-Contributors
- Oura — *Readiness Score* —
- Oura — _Readiness Score_ —
https://support.ouraring.com/hc/en-us/articles/360025589793-Readiness-Score
- Oura — *Readiness Contributors* —
- Oura — _Readiness Contributors_ —
https://support.ouraring.com/hc/en-us/articles/360057791533-Readiness-Contributors
- Oura — *Readiness Score & Cycle Insights* —
- Oura — _Readiness Score & Cycle Insights_ —
https://ouraring.com/blog/readiness-score-cycle-consideration/
---
+13 -9
View File
@@ -12,7 +12,7 @@ TypeError: Cannot read properties of undefined (reading 'replace')
- **Surface**: `<InsightAdvisorCard>` consuming a legacy v1.4.14 cached
payload (`{changed, stable, drivers, nextSteps, confidence,
limitations}` shape — no `summary`, no `recommendations[]`, no
limitations}` shape — no `summary`, no `recommendations[]`, no
`findings[]`).
- **Crash site**: `src/lib/insights/chart-tokens.ts:61`,
`text.replace(TOKEN_REGEX, "")` with `text === undefined`.
@@ -33,6 +33,7 @@ TypeError: Cannot read properties of undefined (reading 'replace')
`git grep -nE '\.replace\(' src/` returned 82 hits. Categories:
### Safe (string-typed input or runtime-string)
- All `t(...).replace("{name}", value)` patterns (i18n always returns
string).
- `String.toISOString().replace(...)`, `String.toLowerCase().replace(...)`,
@@ -43,12 +44,14 @@ TypeError: Cannot read properties of undefined (reading 'replace')
required string fields in Zod.
### Already-guarded
- `health-chart.tsx:1014–1017` — chained `?.startsWith` short-circuits
before the unguarded `.replace`.
- `health-chart.tsx:1261, 1273` — operates on `t(...)` result (always
string).
### Found + fixed inline (this hotfix)
1. **`src/lib/insights/chart-tokens.ts`** — `stripChartTokens(text)` and
`parseChartTokens(text)` accepted `string` only. Legacy payloads
delivering `undefined` crashed `text.replace(...)` / `text.match(...)`.
@@ -57,8 +60,8 @@ TypeError: Cannot read properties of undefined (reading 'replace')
2. **`src/components/insights/insight-advisor-card.tsx`** — added an
"isUnrenderable" short-circuit (`typeof insight.summary !== "string"
|| !Array.isArray(insight.findings) || !Array.isArray(insight
.recommendations)`) before the rich-card render path, so the legacy
|| !Array.isArray(insight.findings) || !Array.isArray(insight
.recommendations)`) before the rich-card render path, so the legacy
blob hits a self-contained legacy-payload card with the regenerate
CTA instead of trying to call `.replace()` on undefined fields.
Belt-and-suspenders alongside the `legacyPayload` flag from the API.
@@ -70,13 +73,14 @@ TypeError: Cannot read properties of undefined (reading 'replace')
card tried to render a non-renderable blob.
### Not fixed (out of scope, deferred)
- None. All hits classified as safe or fixed.
## Verification
- `pnpm test --run src/lib/insights/__tests__/chart-tokens.test.ts
src/lib/ai/__tests__/legacy-payload-detection.test.ts
src/components/insights/__tests__/insight-advisor-card.test.tsx` —
src/lib/ai/__tests__/legacy-payload-detection.test.ts
src/components/insights/__tests__/insight-advisor-card.test.tsx` —
39/39 green.
- The new test reproducing Marc's exact crash scenario (`<InsightAdvisorCard>`
with the v1.4.14 `{changed, stable, ...}` blob) was RED before the
@@ -84,11 +88,11 @@ TypeError: Cannot read properties of undefined (reading 'replace')
## Severity
| Class | Count |
|---|---|
| Crash-on-undefined call sites | 1 (the production bug) |
| Class | Count |
| --------------------------------------- | ---------------------- |
| Crash-on-undefined call sites | 1 (the production bug) |
| Already-guarded but worth strengthening | 0 (no pattern matches) |
| Non-string runtime risk in UI consumers | 0 after this hotfix |
| Non-string runtime risk in UI consumers | 0 after this hotfix |
The production crash was the only real instance. The audit confirms no
analogous time-bombs in other UI consumers.
+4 -4
View File
@@ -6,8 +6,8 @@ We surveyed five reference systems before sketching the new HealthLog
achievement set:
- **Apple Health (Activity rings, Workouts)** — month-of-perfect-days,
ring-on-fire weeks, doubled-goal challenges. Tone is *celebratory but
optional*; nothing shames you for missing a ring.
ring-on-fire weeks, doubled-goal challenges. Tone is _celebratory but
optional_; nothing shames you for missing a ring.
- **Withings (Health+)** — milestone-based ("First weigh-in", "100
weigh-ins"), trend-based ("Lost 1 kg", "Maintained ±2 kg for 30 d"),
and seasonal challenges. Notable: never fires a coercive "you missed
@@ -25,7 +25,7 @@ achievement set:
Keep the existing four (medication / vitals / security / engagement)
and introduce two new buckets: **mood** and **hidden** (the Easter-egg
group). Add two cross-cutting *axes* that aren't categories per se:
group). Add two cross-cutting _axes_ that aren't categories per se:
discovery (only earnable when the user has data for the metric) and
celebration (a special toast when a hidden one fires).
@@ -48,7 +48,7 @@ the precondition predicate is satisfied. Predicate examples:
`PULSE` measurement
- medication badges → user has at least one active `Medication`
Hidden Easter-eggs are *always shown as opaque "?" cards* but never
Hidden Easter-eggs are _always shown as opaque "?" cards_ but never
leak their conditions in the DOM, so a curious user knows the slot
exists without learning the trigger.
+156
View File
@@ -0,0 +1,156 @@
# v1.4.19 backlog seed
Source: phase-D reconcile (v1.4.18). Items below are deferred from
the QA reviewers (code-review / security / design / senior-dev /
simplify). Strategic v1.5 items live in `.planning/v15-backlog.md`
and `.planning/phase-D-v1418-product-lead-review.md`.
Compiled: 2026-05-10.
---
## HIGH (deferred — fix in v1.4.19)
### From security
- **HIGH-2 — i18n bundle ships hidden-achievement strings to every
client.** The redaction landed at the API layer (v1.4.18 reconcile
commit `545f44c`) but `messages/en.json` and `messages/de.json`
are statically `import`-ed into the client bundle, so a determined
user can `Cmd-F` `"hiddenNightOwl"` in `_next/static/chunks/*.js`.
Two viable approaches: (1) build-time strip + on-demand fetch when
an unlock fires, (2) reversible obfuscation (rot13/base64) decoded
client-side only when `unlocked === true`. Approach 1 is the v1.5
fix, approach 2 is the v1.4.19 stopgap. File: `src/lib/i18n/context.tsx:15-16`,
`messages/en.json` "achievements.badges.hidden*".
## MED (deferred)
### From code-review
- **M1** — `countMeasurementsByType` only counts `BLOOD_PRESSURE_SYS`
toward `bpCount`; DIA-only writes (none today) wouldn't earn BP
badges. `bpCount = max(sys, dia)` if Withings ever ships DIA-only.
File: `src/lib/gamification/expansion-metrics.ts:74-83`.
- **M3** — `e2e/admin-api-tokens-mobile.spec.ts` only covers the
AdminShell strip; mirror the spec for `/settings/profile` so the
SettingsShell pillstrip is also Playwright-guarded against future
scrollbar regressions. File: `e2e/admin-api-tokens-mobile.spec.ts`.
- **M4** — `chart-overlay-prefs` route doesn't capture previous flag
state in its `annotate()` call. Capture from/to so audit trail can
reconstruct toggle history. File:
`src/app/api/dashboard/chart-overlay-prefs/route.ts`.
- **M5** — `health-chart-overlay-defaults.test.tsx` reads source
text via `readFileSync` + regex; replace with a SSR render
assertion for `data-slot="chart-personal-baseline-line"`.
Files: `src/components/charts/__tests__/health-chart-overlay-defaults.test.tsx`.
### From security
- **MED-1** — Same race condition documented in code-review H1; fix
shipped in v1.4.18 (commit `cf75579`). Backlog entry retained for
the broader pattern: every JSON-blob PUT route should follow the
same Serializable-transaction shape. Audit:
`src/app/api/dashboard/widgets/route.ts:96-115` (B8 baseline still
has the un-transactioned R-M-W).
### From design
- **M2 design** — `recent-achievements-card.tsx` iconMap missing 8
icons used by v1.4.18 expansion (`Smile`, `Scale`, `CalendarDays`,
`Moon`, `Sun`, `Sparkles`, `FileText`, `Languages`). Falls back to
`Star`. Either mirror the achievements page iconMap or extract a
shared `gamificationIconMap` module. File:
`src/components/gamification/recent-achievements-card.tsx:32-47`.
- **M3 design** — `chart-overlay-prefs` route still missing
`withIdempotency()` wrapper. CLAUDE.md says all POST/PUT/PATCH/DELETE
routes wrap. Single-tenant low-priority; flag for v1.5 multi-tenant.
- **M4 design** — Cog visual contrast vs. range tabs is muted. After
the 44x44 size fix (commit `194ec2f`), bump
`text-muted-foreground` → `text-foreground/80` so the cog reads
as a peer. Polish, not function.
- **M5 design** — Mobile section strip swipe affordance now
invisible (no scrollbar, no fade gradient). Add a right-edge fade
gradient via `.no-scrollbar-with-fade` so users see "more content
this way". Files: `src/app/globals.css:217-223`,
`src/components/admin/admin-shell.tsx:160`,
`src/components/settings/settings-shell.tsx:143`.
- **M6 design** — `/achievements` hidden-category heading prints
`0 / 6` for fresh users, telling them exactly how many hidden
Easter-eggs exist. Suppress denominator: `{unlocked} / ?` for the
hidden category only. File:
`src/app/achievements/page.tsx:421-424`.
### From senior-dev
- **F1 senior-dev** — `ChartOverlayPrefs` type duplication. Resolved
in v1.4.18 reconcile (commit `720e6c8`).
- **F2 senior-dev** — `getAchievementCategory` deprecated wrapper.
Resolved in v1.4.18 reconcile (commit `720e6c8`).
- **F3 senior-dev** — `src/lib/gamification/achievements.ts` (839
LOC) is at the watchpoint for a `definitions/ + evaluators/ +
utilities/` split. Defer until a 5th responsibility (cross-user
leaderboard, server-side rules engine) lands.
- **F4 senior-dev** — `toBerlinDayKey()` reimplemented in
`src/lib/analytics/bp-in-target.ts` AND
`src/lib/gamification/achievements.ts`. Promote to
`src/lib/dates/berlin-day-key.ts` once a third caller arrives.
- **F5 senior-dev** — `src/app/api/gamification/achievements/route.ts`
(876 LOC) — extract metric helpers to
`src/lib/gamification/route-helpers.ts` so route stays "fetch +
serialise" only. Worth doing before v1.5 multi-tenant prep.
## LOW (deferred)
### From code-review
- **L1** — `parseDayKey` in `expansion-metrics.ts:230-232` redundantly
sets `cursor.setUTCHours(12,0,0,0)` after constructing a noon-UTC
date. Drop the no-op line.
- **L2** — i18n parity for the v1.4.18 expansion strings — guard test
exists; no action needed.
### From design
- **L1 design** — `MedicationComplianceChart` hard-codes
`useChartOverlayPrefs("medications")` with no prop. Mirror the
HealthChart contract (`chartKey?: ChartOverlayKey`).
- **L3 design** — `chart-overlay-controls` icon contrast at
`text-muted-foreground` reads ≈3.5:1 default; `text-foreground/70`
bumps to 5:1 default. Pair with M4 design polish.
- **L4 design** — `chart-overlay-controls` dropdown content width
240 px; consider `min-w-[200px] max-w-[260px]` so DE-string-length
doesn't crowd the right edge on 360 px viewports. Add explicit
`collisionPadding={8}` to the radix Popover.
- **L5 design** — `/insights` charts intentionally don't get the cog
but no caption explains why; consider a small
"overlays-set-per-dashboard-card" cue.
### From senior-dev
- **F6 senior-dev** — Hidden-discovery defensive switch case in
`isEarnable`. Resolved in v1.4.18 reconcile (commit `720e6c8`)
by merging into the no-precondition block.
- **F7 senior-dev** — AdminShell + SettingsShell mobile-strip markup
duplication. Defer until a third section shell appears.
- **F8 senior-dev** — `findClosestDia` is O(n·m); fine at current
scale. Add a binary-search variant if a multi-year backfill ever
lands.
## Notes / process
- Format drift accumulated across v1.4.x; cleared in v1.4.18
reconcile (commit `3048dd6` — 72 files swept). Future PRs should
carry a prettier pass.
- The `.no-scrollbar` utility risk noted by Product-Lead (E.2) is
not yet acted on. Either rename to `.no-scrollbar-horizontal` +
assert it's only on `overflow-x` parents, or audit usage on every
PR. Pair with M5 design fade-gradient.
- Strict-schema legacy-payload pattern audit (Product-Lead E.3) is
worth a v1.4.19 sweep: `git grep "safeParse" src/lib/` and confirm
every parse has a graceful fallback path, not a silent crash.
- Hidden-achievement DOM/wire/bundle defense in depth (Product-Lead
E.6) — add a Playwright assertion that greps the rendered page
source for any `ACHIEVEMENT_DEFINITIONS` string when the user
hasn't unlocked it. The redaction landed at the API; a regression
guard at the page level closes the trap door.
+26
View File
@@ -42,6 +42,32 @@ review.
Marc's 6-week focus pick from the review: **C.1 + C.2 + C.4 + C.7 +
C.10 + C.11a Apple Health**.
**v1.4.18 Product-Lead update** (`phase-D-v1418-product-lead-review.md`):
add `C.12 — Per-user UI preference persistence as a platform pattern`
as a running side-quest (extracts the v1.4.18 A3
`useChartOverlayPrefs` shape into a reusable primitive, applies to
sidebar collapsed state, dashboard tile order, per-section default
time-window, per-channel notification prefs). Sized S per surface,
M for the extracted hook + route. Parallelisable with C.7. Drops
nothing from the original 6-week pick.
Three additional v1.5 candidates from the v1.4.18 review:
- **C.13** Achievement engine as a "next-best-action" predicate
engine. The v1.4.18 evaluators are pure predicates over user data;
extend to "you're 1 day from `mood-streak-7`" dashboard nudges. M.
- **C.14** Hidden-achievement pattern as "discover features"
tutorial. Reuses the opaque-card + unlock-on-trigger primitive for
product discovery (Connect a wearable, Set up automation, Export
for your doctor). M. Replaces the v1.4.15 onboarding tour.
- **C.15** Engagement loop on top of the achievement surface
(streak repair tokens / monthly digest emails / friend-only
leaderboards). S/M/M. Privacy-first social.
- **C.16** Admin/Settings shell layout audit. Run a focused pass
across all 13 admin sections + 10 settings sections at three
viewports (Pixel-5, iPad portrait, desktop) with Playwright +
scrollWidth diffing. Catalog every overflow. S–M.
**Section D — v1.6+ follow-on initiatives:**
1. Compliance-export admin tool (off B4 audit-log filter API)