mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
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:
1 parent
3048dd6637
commit
395129a18c
53 files changed
+3195
-2151
No files matched your search
+36
-36
@@ -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
@@ -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.
|
||||
|
||||
@@ -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`).
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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`
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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.*`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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**
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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**.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
---
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user