chore(format): prettier sweep across develop

Mostly table column normalisation under docs/audit/ + planning, object-
literal expansion in src/lib/ai/coach/snapshot.ts after the W4 F6 Apple
Health metric block landed, and small wrap/unwrap line adjustments in
the device + APNs routes / tests touched across W3/W4/W5 sessions.
No behavioural change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-11 08:27:51 +02:00
1 parent 5dcd3954a0
commit f1e6630958
70 files changed
+899 -660

No files matched your search

+47 -46
View File
@@ -44,14 +44,14 @@ Reserved next strategic milestones:
## Previous milestone — v1.4.22 (completed 2026-05-10T22:43:50+02:00) ## Previous milestone — v1.4.22 (completed 2026-05-10T22:43:50+02:00)
| Wave | Goal | State | | Wave | Goal | State |
| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- |
| 1 | Research + probe — Playwright PROD probe (BD-Zielbereich 5th attempt, api-tokens 5th attempt, metric token leaks, Targets page brainstorm); health-coach prompt research | done | | 1 | Research + probe — Playwright PROD probe (BD-Zielbereich 5th attempt, api-tokens 5th attempt, metric token leaks, Targets page brainstorm); health-coach prompt research | done |
| 2 | Insights surface polish — A1 BD framing, A2 BD-Kachel parity, A3 comparison-toggle global, A4 grid normalisation, A5 Muster rename + tabs above hero, A6 token-leak fix | done | | 2 | Insights surface polish — A1 BD framing, A2 BD-Kachel parity, A3 comparison-toggle global, A4 grid normalisation, A5 Muster rename + tabs above hero, A6 token-leak fix | done |
| 3 | Coach polish — B1 prompt rewrite (PROMPT_VERSION 4.20.2 → 4.22.0), B2 collapsible evidence, B3 Gravatar parity, B4 disclaimer move, B5 settings cog removal | done | | 3 | Coach polish — B1 prompt rewrite (PROMPT_VERSION 4.20.2 → 4.22.0), B2 collapsible evidence, B3 Gravatar parity, B4 disclaimer move, B5 settings cog removal | done |
| 4 | Other surfaces + backlog cleanup — C1 Zielwerte sparkline + Δ-vs-last-month, C2 api-tokens 5th attempt, C3 Coolify auto-deploy runbook, C4 AuthShell flicker → proxy.ts, C5 node-26-alpine deferred, D backlog wave (12 items) | done | | 4 | Other surfaces + backlog cleanup — C1 Zielwerte sparkline + Δ-vs-last-month, C2 api-tokens 5th attempt, C3 Coolify auto-deploy runbook, C4 AuthShell flicker → proxy.ts, C5 node-26-alpine deferred, D backlog wave (12 items) | done |
| 5 | Multi-agent QA + Product-Lead — code, security, design, senior-dev, simplify, product-lead; 0 CRIT, 7 HIGH all applied inline, ~6 MED applied, rest deferred | done | | 5 | Multi-agent QA + Product-Lead — code, security, design, senior-dev, simplify, product-lead; 0 CRIT, 7 HIGH all applied inline, ~6 MED applied, rest deferred | done |
| 6 | Release v1.4.22 — bump, CHANGELOG, release-merge, tag, GHCR, host-side retag deploy (Coolify secrets still missing), /api/version=1.4.22, smoke, GH release, docs+landing sync, brief | done | | 6 | Release v1.4.22 — bump, CHANGELOG, release-merge, tag, GHCR, host-side retag deploy (Coolify secrets still missing), /api/version=1.4.22, smoke, GH release, docs+landing sync, brief | done |
Milestone completed 2026-05-10T22:43:50+02:00 — v1.4.22 LIVE in prod. Milestone completed 2026-05-10T22:43:50+02:00 — v1.4.22 LIVE in prod.
Release brief: `docs/audit/v1422-summary.md`. Release brief: `docs/audit/v1422-summary.md`.
@@ -64,8 +64,8 @@ Backlog seeded to `.planning/v1422-backlog.md`. v1.5 strategic plan at
## Previous patch — v1.4.21 (completed 2026-05-10T17:46+00:00) ## Previous patch — v1.4.21 (completed 2026-05-10T17:46+00:00)
| Phase | Goal | State | | Phase | Goal | State |
| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- |
| F | Same-day patch on top of v1.4.20 — Daily Briefing regenerate fix, Coach day-level snapshot, duplicate streaming bubble, drawer header, scope picker UI | done | | F | Same-day patch on top of v1.4.20 — Daily Briefing regenerate fix, Coach day-level snapshot, duplicate streaming bubble, drawer header, scope picker UI | done |
| E | Release v1.4.21 — develop → main release-merge, bump, CHANGELOG, tag, GHCR, host-side retag fallback, /api/version=1.4.21, smoke, GH release, brief | done | | E | Release v1.4.21 — develop → main release-merge, bump, CHANGELOG, tag, GHCR, host-side retag fallback, /api/version=1.4.21, smoke, GH release, brief | done |
@@ -78,21 +78,21 @@ Image digest:
## Previous milestone — v1.4.20 (completed 2026-05-10T16:49:25Z) ## Previous milestone — v1.4.20 (completed 2026-05-10T16:49:25Z)
| Phase | Goal | State | | Phase | Goal | State |
| ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- |
| F0 | Bootstrap — commit dangling v1.4.19 reports, scaffold STATE+ROADMAP | done | | F0 | Bootstrap — commit dangling v1.4.19 reports, scaffold STATE+ROADMAP | done |
| F1 | Branch model — long-lived `develop` from `main` HEAD; GHCR builds on `main` + `v*` only | done | | F1 | Branch model — long-lived `develop` from `main` HEAD; GHCR builds on `main` + `v*` only | done |
| F2 | Document branch + release model — CONTRIBUTING.md + docs site mirror page | done | | F2 | Document branch + release model — CONTRIBUTING.md + docs site mirror page | done |
| FX | User-facing artifact cleanup — PII (no real names, no health figures), internal jargon, German leaks; CHANGELOG + docs/audit + GH releases + docs + landing | done | | FX | User-facing artifact cleanup — PII (no real names, no health figures), internal jargon, German leaks; CHANGELOG + docs/audit + GH releases + docs + landing | done |
| F5 | Best-practice GitHub repo audit — CODE_OF_CONDUCT.md, issue + PR templates, dependabot expansion, package.json metadata | done | | F5 | Best-practice GitHub repo audit — CODE_OF_CONDUCT.md, issue + PR templates, dependabot expansion, package.json metadata | done |
| F6 | Multi-agent QA on new docs — 38 docs pages cross-checked vs deployed state, CRIT + HIGH inline | done | | F6 | Multi-agent QA on new docs — 38 docs pages cross-checked vs deployed state, CRIT + HIGH inline | done |
| B1 | Hero strip + Daily Briefing + Suggested-prompts | done | | B1 | Hero strip + Daily Briefing + Suggested-prompts | done |
| B2 | AI Coach drawer + SSE streaming + encrypted persistence | done | | B2 | AI Coach drawer + SSE streaming + encrypted persistence | done |
| B3 | Correlation discovery + Trends row with AI annotations | done | | B3 | Correlation discovery + Trends row with AI annotations | done |
| B4 | Weekly Report + Storyboard markers + Mobile passes | done | | B4 | Weekly Report + Storyboard markers + Mobile passes | done |
| B5 | Personal Health Score (composite 0–100, 3 bands) | done | | B5 | Personal Health Score (composite 0–100, 3 bands) | done |
| D | Multi-agent QA — code, security, design, senior-dev, simplify, product-lead; 13 HIGH + 6 MED + 5 simplify-yes inline; 22 MED + 16 LOW deferred | done | | D | Multi-agent QA — code, security, design, senior-dev, simplify, product-lead; 13 HIGH + 6 MED + 5 simplify-yes inline; 22 MED + 16 LOW deferred | done |
| E | Release v1.4.20 — develop → main release-merge, bump, CHANGELOG, tag, GHCR, host-side retag deploy fallback, /api/version=1.4.20, smoke, docs+landing sync | done | | E | Release v1.4.20 — develop → main release-merge, bump, CHANGELOG, tag, GHCR, host-side retag deploy fallback, /api/version=1.4.20, smoke, docs+landing sync | done |
Milestone completed 2026-05-10T16:49:25Z — v1.4.20 LIVE in prod. Milestone completed 2026-05-10T16:49:25Z — v1.4.20 LIVE in prod.
Release brief: `docs/audit/v1420-summary.md`. Backlog seeded to Release brief: `docs/audit/v1420-summary.md`. Backlog seeded to
@@ -103,20 +103,20 @@ Release brief: `docs/audit/v1420-summary.md`. Backlog seeded to
## Previous milestone — v1.4.19 (completed 2026-05-10T12:39:59Z) ## Previous milestone — v1.4.19 (completed 2026-05-10T12:39:59Z)
| Phase | Goal | State | | Phase | Goal | State |
| ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ----- |
| 0 | Bootstrap — STATE+ROADMAP for v1.4.19 | done | | 0 | Bootstrap — STATE+ROADMAP for v1.4.19 | done |
| A1 | BD-Zielbereich constant 50% (4th attempt) — live-DB root-cause + integration test | done | | A1 | BD-Zielbereich constant 50% (4th attempt) — live-DB root-cause + integration test | done |
| A2 | Charts mobile audit — universal X-tick density helper, mobile-first stacked headers across HealthChart / MoodChart / MedicationComplianceChart | done | | A2 | Charts mobile audit — universal X-tick density helper, mobile-first stacked headers across HealthChart / MoodChart / MedicationComplianceChart | done |
| A3 | `/insights` polish — Comparison-toggle relocated, single page-level refresh, BP/Weight tile strip removed, raw-token leak guarded | done | | A3 | `/insights` polish — Comparison-toggle relocated, single page-level refresh, BP/Weight tile strip removed, raw-token leak guarded | done |
| A4 | AI prompt — GROUND RULE 7 forbids default-positivity opener, PROMPT_VERSION 4.16.1 → 4.19.0 | done | | A4 | AI prompt — GROUND RULE 7 forbids default-positivity opener, PROMPT_VERSION 4.16.1 → 4.19.0 | done |
| A5 | Settings/Integrations status-UI — `<IntegrationStatusPill>` consolidates Withings + Mood Log, divider parity, locale-aware relative-time | done | | A5 | Settings/Integrations status-UI — `<IntegrationStatusPill>` consolidates Withings + Mood Log, divider parity, locale-aware relative-time | done |
| A6 | Settings mobile audit — input heights equalised at 36 px, action buttons standardised, Sprache select hoisted to its own row | done | | A6 | Settings mobile audit — input heights equalised at 36 px, action buttons standardised, Sprache select hoisted to its own row | done |
| A7 | Admin polish — feedback tab strip scrollbar, api-tokens 4th-attempt truncate+tooltip, Einklappen toggle removed, Zielwerte i18n | done | | A7 | Admin polish — feedback tab strip scrollbar, api-tokens 4th-attempt truncate+tooltip, Einklappen toggle removed, Zielwerte i18n | done |
| A8 | Quality-of-life audit — 78 findings prioritised CRITICAL / HIGH / MED / LOW | done | | A8 | Quality-of-life audit — 78 findings prioritised CRITICAL / HIGH / MED / LOW | done |
| B | Apply A8 — 6/6 CRITICAL + 21/25 HIGH inline; 31 MED + 16 LOW carried to v1.4.20 backlog | done | | B | Apply A8 — 6/6 CRITICAL + 21/25 HIGH inline; 31 MED + 16 LOW carried to v1.4.20 backlog | done |
| D | Multi-agent QA (5 reviewers) + Product-Lead → v1.5 redesign plan filed | done | | D | Multi-agent QA (5 reviewers) + Product-Lead → v1.5 redesign plan filed | done |
| E1-E3 | Release v1.4.19 — tag, GHCR build green, host-side retag deploy via fallback (Coolify deploy hung, retried via SSH), GH release, docs+landing sync | done | | E1-E3 | Release v1.4.19 — tag, GHCR build green, host-side retag deploy via fallback (Coolify deploy hung, retried via SSH), GH release, docs+landing sync | done |
Milestone completed 2026-05-10T12:39:59Z — v1.4.19 LIVE in prod. Milestone completed 2026-05-10T12:39:59Z — v1.4.19 LIVE in prod.
Release brief: `docs/audit/v1419-summary.md`. Backlog seeded to Release brief: `docs/audit/v1419-summary.md`. Backlog seeded to
@@ -127,20 +127,21 @@ Release brief: `docs/audit/v1419-summary.md`. Backlog seeded to
## Previous milestone — v1.4.18 (completed 2026-05-10T11:45+02:00) ## Previous milestone — v1.4.18 (completed 2026-05-10T11:45+02:00)
| Phase | Goal | State | | Phase | Goal | State |
| ----- | ---------------------------------------------------------------------------------------------------------- | ----- | | ----- | ------------------------------------------------------------------------------------------------------------ | ----- |
| 0 | Bootstrap — STATE+ROADMAP for v1.4.18 | done | | 0 | Bootstrap — STATE+ROADMAP for v1.4.18 | done |
| A1 | BD-Zielbereich tile — 7T/30T sub-values render real numbers (currently "—" even with data) | done | | A1 | BD-Zielbereich tile — 7T/30T sub-values render real numbers (currently "—" even with data) | done |
| A2 | `/admin/api-tokens` table scrollbar (3rd attempt) — Playwright live-verify against prod, fix actual offender | done | | A2 | `/admin/api-tokens` table scrollbar (3rd attempt) — Playwright live-verify against prod, fix actual offender | done |
| A3 | Chart visual revert + per-chart toggles — drop gradient/emoji/auto-mean, ship 3 opt-in overlay toggles | done | | A3 | Chart visual revert + per-chart toggles — drop gradient/emoji/auto-mean, ship 3 opt-in overlay toggles | done |
| B1 | Achievements expansion — research + 15-25 new, hidden Easter-eggs, lock filter, hidden cards | done | | B1 | Achievements expansion — research + 15-25 new, hidden Easter-eggs, lock filter, hidden cards | done |
| D | Multi-agent QA + Product-Lead — code-reviewer, security, design, senior, simplify, product | done | | D | Multi-agent QA + Product-Lead — code-reviewer, security, design, senior, simplify, product | done |
| E | Release v1.4.18 — bump, CHANGELOG, tag, GHCR, deploy, /api/version=1.4.18, smoke, docs+landing sync | done | | E | Release v1.4.18 — bump, CHANGELOG, tag, GHCR, deploy, /api/version=1.4.18, smoke, docs+landing sync | done |
Marathon completed 2026-05-10T11:45+02:00 — v1.4.18 LIVE in prod. Marathon completed 2026-05-10T11:45+02:00 — v1.4.18 LIVE in prod.
Full report: `docs/audit/v1418-summary.md`. Backlog seeded to Full report: `docs/audit/v1418-summary.md`. Backlog seeded to
`.planning/v1419-backlog.md` (tactical) and `.planning/v15-backlog.md` `.planning/v1419-backlog.md` (tactical) and `.planning/v15-backlog.md`
+ `.planning/phase-D-v1418-product-lead-review.md` (strategic).
- `.planning/phase-D-v1418-product-lead-review.md` (strategic).
--- ---
+16 -16
View File
@@ -13,15 +13,15 @@ Status: complete · 2026-05-09T13:18+02:00
## Commits on origin/main ## Commits on origin/main
| SHA | Message | Concern | | SHA | Message | Concern |
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| b5e9a95 | (A4 sweep) — `<CompareToggle />` removal in `src/app/page.tsx` was swept into A4's AI-prompt commit when our edits raced. Effectively covered. | 1 | | b5e9a95 | (A4 sweep) — `<CompareToggle />` removal in `src/app/page.tsx` was swept into A4's AI-prompt commit when our edits raced. Effectively covered. | 1 |
| 60a91af | feat(insights): comparison toggle on-surface in hero meta band — adds `metaSlot` prop to `<InsightsPageHero>`, mounts toggle inline. | 5 | | 60a91af | feat(insights): comparison toggle on-surface in hero meta band — adds `metaSlot` prop to `<InsightsPageHero>`, mounts toggle inline. | 5 |
| 98a3d10 | refactor(insights): single page-level refresh and drop orphan subtitle — drops `onRegenerate` from the page's `<InsightAdvisorCard>` mount + the `aiOverviewTitle` placeholder. | 2 + 3 | | 98a3d10 | refactor(insights): single page-level refresh and drop orphan subtitle — drops `onRegenerate` from the page's `<InsightAdvisorCard>` mount + the `aiOverviewTitle` placeholder. | 2 + 3 |
| 335f288 | refactor(insights): drop duplicate metric tile strip — removes 5-tile grid + dead helpers + `fmt`/`bf` locals. Net `-157` / `+20` lines. | 4 | | 335f288 | refactor(insights): drop duplicate metric tile strip — removes 5-tile grid + dead helpers + `fmt`/`bf` locals. Net `-157` / `+20` lines. | 4 |
| fa91a73 | fix(insights): strip lowercase chart tokens from AI prose — splits `STRIP_TOKEN_REGEX` (`[A-Za-z0-9_]+`) from `PARSE_TOKEN_REGEX` (`[A-Z_]+`). | 6 | | fa91a73 | fix(insights): strip lowercase chart tokens from AI prose — splits `STRIP_TOKEN_REGEX` (`[A-Za-z0-9_]+`) from `PARSE_TOKEN_REGEX` (`[A-Z_]+`). | 6 |
| 5360d3c | test(insights): coverage for v1.4.19 polish changes — commit message landed but the test file was missing because a parallel agent's index race captured only the planning files. | | 5360d3c | test(insights): coverage for v1.4.19 polish changes — commit message landed but the test file was missing because a parallel agent's index race captured only the planning files. |
| f2b21a4 | test(insights): add insights-polish.test.ts (followup to 5360d3c) — actual test file (9 tests), pinning all of the above. | 7 | | f2b21a4 | test(insights): add insights-polish.test.ts (followup to 5360d3c) — actual test file (9 tests), pinning all of the above. | 7 |
## Verification ## Verification
@@ -40,13 +40,13 @@ Status: complete · 2026-05-09T13:18+02:00
## File deltas ## File deltas
| File | Lines added | Lines removed | | File | Lines added | Lines removed |
| ----------------------------------------------------- | ----------- | ------------- | | -------------------------------------------------- | ----------- | ------------- |
| `src/app/insights/page.tsx` | +43 | -181 | | `src/app/insights/page.tsx` | +43 | -181 |
| `src/components/insights/insights-page-hero.tsx` | +22 | 0 | | `src/components/insights/insights-page-hero.tsx` | +22 | 0 |
| `src/components/insights/insight-advisor-card.tsx` | +12 | -5 | | `src/components/insights/insight-advisor-card.tsx` | +12 | -5 |
| `src/lib/insights/chart-tokens.ts` | +16 | -7 | | `src/lib/insights/chart-tokens.ts` | +16 | -7 |
| `src/app/__tests__/insights-polish.test.ts` | +140 | 0 | | `src/app/__tests__/insights-polish.test.ts` | +140 | 0 |
Net `-200` lines on the page itself, `+140` lines of guards; `/insights` is significantly leaner without losing any value. Net `-200` lines on the page itself, `+140` lines of guards; `/insights` is significantly leaner without losing any value.
+5 -5
View File
@@ -3,11 +3,11 @@
Completed 2026-05-10 ~13:05 CEST. Three atomic TDD-first commits on Completed 2026-05-10 ~13:05 CEST. Three atomic TDD-first commits on
`origin/main`: `origin/main`:
| # | SHA | What it ships | | # | SHA | What it ships |
| --- | --------- | ------------------------------------------------------------------------------ | | --- | --------- | ------------------------------------------------------------------------------- |
| 1 | `ba0d6b8` | `feat(integrations): IntegrationStatusPill component (single tag, mobile-safe)`| | 1 | `ba0d6b8` | `feat(integrations): IntegrationStatusPill component (single tag, mobile-safe)` |
| 2 | `0dcc91a` | `refactor(integrations): single status pill, drop redundant containers` | | 2 | `0dcc91a` | `refactor(integrations): single status pill, drop redundant containers` |
| 3 | `47a8fc7` | `test(integrations): Pixel-5 e2e for consolidated status pill` | | 3 | `47a8fc7` | `test(integrations): Pixel-5 e2e for consolidated status pill` |
Commit #2 absorbed Marc's planned commits #2 (Withings) and #3 Commit #2 absorbed Marc's planned commits #2 (Withings) and #3
(Mood Log) — the refactor was inherently atomic because both cards (Mood Log) — the refactor was inherently atomic because both cards
+5 -5
View File
@@ -36,11 +36,11 @@ human eye missed.
## What was fixed (5 commits) ## What was fixed (5 commits)
1. `957f8e9` — `fix(settings): equalize input heights across all 1. `957f8e9` — `fix(settings): equalize input heights across all
settings sections`. AI active-provider select `h-10` → `h-9`, AI settings sections`. AI active-provider select `h-10` → `h-9`, AI
add-provider select `h-8` → `h-9`, Dashboard "Compare to" trigger add-provider select `h-8` → `h-9`, Dashboard "Compare to" trigger
loses `min-h-11` so the default `h-9` applies. loses `min-h-11` so the default `h-9` applies.
2. `9fda634` — `fix(settings): action-button placement consistent 2. `9fda634` — `fix(settings): action-button placement consistent
across sections (right-side desktop, stacked mobile)`. Account → across sections (right-side desktop, stacked mobile)`. Account →
Password, Account → Restart onboarding tour and Dashboard → Reset Password, Account → Restart onboarding tour and Dashboard → Reset
to defaults all use `flex-col` on `<sm` and `flex-row` on `>=sm`, to defaults all use `flex-col` on `<sm` and `flex-row` on `>=sm`,
so the button stacks below the title on mobile (full-width) and so the button stacks below the title on mobile (full-width) and
@@ -48,21 +48,21 @@ human eye missed.
pulled into the title cluster on the two account cards so each pulled into the title cluster on the two account cards so each
row is a clean `<title-block + button>` flex pair. row is a clean `<title-block + button>` flex pair.
3. `1075784` — `fix(settings): Sprache select position + width 3. `1075784` — `fix(settings): Sprache select position + width
consistent`. Language lifted out of the dob/language pair into consistent`. Language lifted out of the dob/language pair into
its own row at the bottom of the Profile card. `max-w-xs` on its own row at the bottom of the Profile card. `max-w-xs` on
`>=sm` so it doesn't render heavier than other half-width fields. `>=sm` so it doesn't render heavier than other half-width fields.
Drive-by: pulled the duplicated native-select class string into a Drive-by: pulled the duplicated native-select class string into a
single `NATIVE_SELECT_CLASS` constant so gender + language render single `NATIVE_SELECT_CLASS` constant so gender + language render
identically. identically.
4. `737b533` — `style(settings): uniform vertical spacing across all 4. `737b533` — `style(settings): uniform vertical spacing across all
sections`. Dashboard layout card `space-y-5` → `space-y-4`. AI sections`. Dashboard layout card `space-y-5` → `space-y-4`. AI
provider config forms `space-y-3` → `space-y-4`. Section roots provider config forms `space-y-3` → `space-y-4`. Section roots
keep `space-y-6` so distinct cards still read as distinct keep `space-y-6` so distinct cards still read as distinct
surfaces. Note: `.planning/STATE.md` got bundled into this commit surfaces. Note: `.planning/STATE.md` got bundled into this commit
accidentally via index race with A5; STATE content is correct, accidentally via index race with A5; STATE content is correct,
no semantic harm. no semantic harm.
5. `78f1f3f` — `test(settings): mobile e2e snapshot verifies 5. `78f1f3f` — `test(settings): mobile e2e snapshot verifies
consistency`. Pixel-5 Playwright spec consistency`. Pixel-5 Playwright spec
(`e2e/settings-mobile-consistency.spec.ts`) locks in five (`e2e/settings-mobile-consistency.spec.ts`) locks in five
invariants: every form input on /settings/account is 36 px, invariants: every form input on /settings/account is 36 px,
neither password nor tour buttons overflow the card right edge, neither password nor tour buttons overflow the card right edge,
+5
View File
@@ -16,6 +16,7 @@ Commits on `origin/main`:
## What landed ## What landed
### 1. Feedback tab strip mini-scrollbar — fixed ### 1. Feedback tab strip mini-scrollbar — fixed
Marc's "mini Scroll-Button rechts" on the admin feedback inbox tabs Marc's "mini Scroll-Button rechts" on the admin feedback inbox tabs
came from a CSS overflow-axis coupling: `tabsListVariants` set came from a CSS overflow-axis coupling: `tabsListVariants` set
`overflow-x: auto` without `overflow-y`, so the browser silently `overflow-x: auto` without `overflow-y`, so the browser silently
@@ -27,6 +28,7 @@ short strips. Added `overflow-y-hidden` (with the matching
component test that locks both axes in. component test that locks both axes in.
### 2. /admin/api-tokens 4th-attempt scrollbar — fixed ### 2. /admin/api-tokens 4th-attempt scrollbar — fixed
Three previous fixes (column-hide v1.4.15, mobile card-list Three previous fixes (column-hide v1.4.15, mobile card-list
v1.4.16, admin-shell mobile-strip `no-scrollbar` v1.4.18) cleared v1.4.16, admin-shell mobile-strip `no-scrollbar` v1.4.18) cleared
the obvious culprits but Marc still saw a residual painted bar. the obvious culprits but Marc still saw a residual painted bar.
@@ -49,6 +51,7 @@ Added an e2e regression that walks every element inside the mobile
card-list and asserts `scrollWidth <= clientWidth + 1` for each. card-list and asserts `scrollWidth <= clientWidth + 1` for each.
### 3. /admin/api-tokens "Einklappen" button — removed ### 3. /admin/api-tokens "Einklappen" button — removed
Inherited from the v1.4 shared-admin page where 13 sections lived Inherited from the v1.4 shared-admin page where 13 sections lived
together. On the v1.5 dedicated `/admin/api-tokens` route the together. On the v1.5 dedicated `/admin/api-tokens` route the
toggle hides the only card on the page. Marc called it "sinnlos". toggle hides the only card on the page. Marc called it "sinnlos".
@@ -57,10 +60,12 @@ Dropped the `expanded` state + button. The shared `settings.collapse`
`<LoginOverviewSection>` still uses them. `<LoginOverviewSection>` still uses them.
### 4. Zielwerte page whitespace — tightened ### 4. Zielwerte page whitespace — tightened
Outer wrapper changed from `space-y-8` (32 px) to `space-y-6` Outer wrapper changed from `space-y-8` (32 px) to `space-y-6`
(24 px), matching the admin / settings rhythm. (24 px), matching the admin / settings rhythm.
### 5. Zielwerte status labels translated ### 5. Zielwerte status labels translated
Added two new i18n key blocks under `targets.*`: Added two new i18n key blocks under `targets.*`:
- `targets.label.<TYPE>` (11 entries, EN + DE) — used by - `targets.label.<TYPE>` (11 entries, EN + DE) — used by
+35 -35
View File
@@ -6,44 +6,44 @@ defer the rest to v1.4.20.
## CRITICAL — 6 / 6 fixed ## CRITICAL — 6 / 6 fixed
| Finding | Status | Commit | Notes | | Finding | Status | Commit | Notes |
| ------- | ------ | ------ | ----- | | ------------------------------------------------------------ | ------ | --------- | --------------------------------------------------------------------------------------------- | ----------------------------- |
| F-01 `formatTimeWindowRange` mixed-language `bis ... Uhr` | fixed | `180f46c` | Locale param threaded through medication card + form. TDD: 6 cases. | | F-01 `formatTimeWindowRange` mixed-language `bis ... Uhr` | fixed | `180f46c` | Locale param threaded through medication card + form. TDD: 6 cases. |
| F-02 `/admin/login-overview` shows insights events | fixed | `dc75fe8` | Section now sets `filter=auth`; action dropdown filtered to `auth.*`. TDD: URL assertion. | | F-02 `/admin/login-overview` shows insights events | fixed | `dc75fe8` | Section now sets `filter=auth`; action dropdown filtered to `auth.*`. TDD: URL assertion. |
| F-03 Achievements `Idiot` / `Lazy Boy` titles | fixed | `7a6bc81` | Renamed in EN + DE; banned-word regression suite. | | F-03 Achievements `Idiot` / `Lazy Boy` titles | fixed | `7a6bc81` | Renamed in EN + DE; banned-word regression suite. |
| F-04 Date inputs render mm/dd/yyyy on DE | fixed | `ff6e184` | `lang={locale}` on every native `<input type="date|datetime-local">` in the app. | | F-04 Date inputs render mm/dd/yyyy on DE | fixed | `ff6e184` | `lang={locale}` on every native `<input type="date | datetime-local">` in the app. |
| F-05 Admin "Pick a section from the sidebar" wrong on mobile | fixed | `4feacad` | Dropped sidebar reference in EN + DE welcome subtitle. | | F-05 Admin "Pick a section from the sidebar" wrong on mobile | fixed | `4feacad` | Dropped sidebar reference in EN + DE welcome subtitle. |
| F-06 Insights legacy CTA verb mismatch | fixed | `d263125` | Legacy-payload button now says "Regenerate" in EN/DE; empty-state "Start analysis" untouched. | | F-06 Insights legacy CTA verb mismatch | fixed | `d263125` | Legacy-payload button now says "Regenerate" in EN/DE; empty-state "Start analysis" untouched. |
## HIGH — 21 fixed / 4 deferred (target: 15) ## HIGH — 21 fixed / 4 deferred (target: 15)
| Finding | Status | Commit / reason | | Finding | Status | Commit / reason |
| ------- | ------ | --------------- | | ------------------------------------------------ | --------------- | ---------------------------------------------------------------------------------------------------- |
| F-07 thresholds three names | fixed | `1f4d6a2` | | F-07 thresholds three names | fixed | `1f4d6a2` |
| F-08 page+card title duplicate | fixed (partial) | `70ebe32` (danger-zone, feedback). Other affected pages deferred. | | F-08 page+card title duplicate | fixed (partial) | `70ebe32` (danger-zone, feedback). Other affected pages deferred. |
| F-09 backup type raw enum | fixed | `1ae9b9d` | | F-09 backup type raw enum | fixed | `1ae9b9d` |
| F-10 `WITHINGS` rendered ALL CAPS | fixed | `1ae9b9d` | | F-10 `WITHINGS` rendered ALL CAPS | fixed | `1ae9b9d` |
| F-11 `→ User` / `→ Admin` no label | fixed | `42b0f11` | | F-11 `→ User` / `→ Admin` no label | fixed | `42b0f11` |
| F-12 mobile user cards no aria-labels | deferred | spot-check showed every icon button already has `aria-label` + `title`. *not-an-issue*; see backlog. | | F-12 mobile user cards no aria-labels | deferred | spot-check showed every icon button already has `aria-label` + `title`. _not-an-issue_; see backlog. |
| F-13 mobile sys/dia same heart icon | fixed | `876e074` | | F-13 mobile sys/dia same heart icon | fixed | `876e074` |
| F-14 settings/integrations status duplicated | deferred | already shipped in v1.4.19 A5. | | F-14 settings/integrations status duplicated | deferred | already shipped in v1.4.19 A5. |
| F-15 ntfy in subtitle, missing on board | fixed | `3f6e670` | | F-15 ntfy in subtitle, missing on board | fixed | `3f6e670` |
| F-16 "Configured" + "Enabled" double badge | fixed | `876e074` | | F-16 "Configured" + "Enabled" double badge | fixed | `876e074` |
| F-17 raw `auth.token.autoissue.native` event-key | fixed | `bc81fd7` | | F-17 raw `auth.token.autoissue.native` event-key | fixed | `bc81fd7` |
| F-18 ISO timestamp in token name | fixed | `713b494` | | F-18 ISO timestamp in token name | fixed | `713b494` |
| F-19 raw `*` permission badge | fixed | `3f6e670` | | F-19 raw `*` permission badge | fixed | `3f6e670` |
| F-20 admin/feedback dupe subtitle | fixed | `3f6e670` | | F-20 admin/feedback dupe subtitle | fixed | `3f6e670` |
| F-21 "system-wide" suffix repeated 4× | fixed | `3f6e670` | | F-21 "system-wide" suffix repeated 4× | fixed | `3f6e670` |
| F-22 services subtitle misses API toggle | fixed | `3f6e670` | | F-22 services subtitle misses API toggle | fixed | `3f6e670` |
| F-23 mood/achievements DE strings | deferred | spot-check showed every label already translated. *not-an-issue*; see backlog. | | F-23 mood/achievements DE strings | deferred | spot-check showed every label already translated. _not-an-issue_; see backlog. |
| F-24 admin/integrations duplicate toggle label | fixed | `98cd13c` | | F-24 admin/integrations duplicate toggle label | fixed | `98cd13c` |
| F-25 `Glitchtip` casing | fixed | `3f6e670` | | F-25 `Glitchtip` casing | fixed | `3f6e670` |
| F-26 insights regenerate banner+button conflict | deferred | partial overlap with A3 + F-06; per-tile spinner removal owns by v1.4.20 Insights redesign. | | F-26 insights regenerate banner+button conflict | deferred | partial overlap with A3 + F-06; per-tile spinner removal owns by v1.4.20 Insights redesign. |
| F-27 awkward achievements subtitle | fixed | `3f6e670` | | F-27 awkward achievements subtitle | fixed | `3f6e670` |
| F-28 plural bug "for 1 consecutive days" | fixed | `98cd13c` | | F-28 plural bug "for 1 consecutive days" | fixed | `98cd13c` |
| F-29 jargon "(severity x provider)" | fixed | `3f6e670` | | F-29 jargon "(severity x provider)" | fixed | `3f6e670` |
| F-30 "Inbox zero" empty-state idiom | fixed | `3f6e670` | | F-30 "Inbox zero" empty-state idiom | fixed | `3f6e670` |
| F-31 audit-preview row no link | fixed | `bc81fd7` | | F-31 audit-preview row no link | fixed | `bc81fd7` |
## MED + LOW — 31 + 16 deferred to v1.4.20 ## MED + LOW — 31 + 16 deferred to v1.4.20
+20 -17
View File
@@ -13,28 +13,29 @@ full-width Daily Briefing card, and extend the AI insight pipeline
with a `dailyBriefing` payload block (paragraph + 0-5 keyFindings). with a `dailyBriefing` payload block (paragraph + 0-5 keyFindings).
The 5 prior commits land each sub-deliverable atomically; this report The 5 prior commits land each sub-deliverable atomically; this report
+ STATE.md tick complete in commit 6.
- STATE.md tick complete in commit 6.
## What shipped (5 commits, all on `origin/develop`) ## What shipped (5 commits, all on `origin/develop`)
| # | SHA | Sub-deliverable | | # | SHA | Sub-deliverable |
|---|-----|------| | --- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | `9873363` | Schema + prompt — `dailyBriefingSchema` (`paragraph`, `keyFindings[0..5]`, tone enum, sourceWindow / sourceMetric enums); PROMPT_VERSION bump 4.19.0 → 4.20.0; GROUND RULE 8 added in EN + DE prompts; new `daily-briefing-schema.test.ts` (+31 specs). | | 1 | `9873363` | Schema + prompt — `dailyBriefingSchema` (`paragraph`, `keyFindings[0..5]`, tone enum, sourceWindow / sourceMetric enums); PROMPT_VERSION bump 4.19.0 → 4.20.0; GROUND RULE 8 added in EN + DE prompts; new `daily-briefing-schema.test.ts` (+31 specs). |
| 2 | `9713bf1` | `<SuggestedPrompts>` chip-strip component — 5-chip default ("Try asking" row), lucide `Quote` icon, Dracula purple chip styling, mobile-wrap; EN + DE keys (`insights.suggestedPrompts.*`); +9 specs. | | 2 | `9713bf1` | `<SuggestedPrompts>` chip-strip component — 5-chip default ("Try asking" row), lucide `Quote` icon, Dracula purple chip styling, mobile-wrap; EN + DE keys (`insights.suggestedPrompts.*`); +9 specs. |
| 3 | `92f332e` | `<DailyBriefing>` card — narrative paragraph, 0-5 finding rows with tone-coloured left bar (green/orange/cyan), metric-icon prefix, optional delta badge, empty-state CTA, `animate-pulse` skeleton, optional metaSlot; EN + DE keys (`insights.dailyBriefing.*`); +18 specs. | | 3 | `92f332e` | `<DailyBriefing>` card — narrative paragraph, 0-5 finding rows with tone-coloured left bar (green/orange/cyan), metric-icon prefix, optional delta badge, empty-state CTA, `animate-pulse` skeleton, optional metaSlot; EN + DE keys (`insights.dailyBriefing.*`); +18 specs. |
| 4 | `59e63a0` | `<HeroStrip>` — locale-aware time-of-day greeting (4 buckets), briefing-paragraph subtitle with fallback, 3-button action row (weekly-report + ask-coach disabled with `title="Coming soon"`, regenerate wired), suggested-prompt strip below; old `<InsightsPageHero>` JSDoc-deprecated; new `globals.css` utilities `.hero-gradient`, `.glow-purple`, `pulseDot` keyframes; +22 specs. | | 4 | `59e63a0` | `<HeroStrip>` — locale-aware time-of-day greeting (4 buckets), briefing-paragraph subtitle with fallback, 3-button action row (weekly-report + ask-coach disabled with `title="Coming soon"`, regenerate wired), suggested-prompt strip below; old `<InsightsPageHero>` JSDoc-deprecated; new `globals.css` utilities `.hero-gradient`, `.glow-purple`, `pulseDot` keyframes; +22 specs. |
| 5 | `7d29596` | `/insights` page wire-up — `<HeroStrip>` + `<DailyBriefing>` mounted above the existing Status / Advisor / Recommendations blocks; `useInsightsAdvisorQuery` lifts `dailyBriefing` off the cached payload via `dailyBriefingSchema.safeParse`; CompareToggle migrated from hero meta band into briefing metaSlot; v1.4.19 A3 polish-test loosened from string-count to JSX-block check (load-bearing "advisor card carries no onRegenerate" stays). | | 5 | `7d29596` | `/insights` page wire-up — `<HeroStrip>` + `<DailyBriefing>` mounted above the existing Status / Advisor / Recommendations blocks; `useInsightsAdvisorQuery` lifts `dailyBriefing` off the cached payload via `dailyBriefingSchema.safeParse`; CompareToggle migrated from hero meta band into briefing metaSlot; v1.4.19 A3 polish-test loosened from string-count to JSX-block check (load-bearing "advisor card carries no onRegenerate" stays). |
## Test count ## Test count
| Snapshot | Files | Tests | | Snapshot | Files | Tests |
|----|----|----| | ---------------------------------- | ----- | ---------------------- |
| Baseline (start of B1) | 210 | 1672 | | Baseline (start of B1) | 210 | 1672 |
| After commit 1 (schema) | 211 | 1703 (+31) | | After commit 1 (schema) | 211 | 1703 (+31) |
| After commit 2 (suggested-prompts) | 212 | 1712 (+9) | | After commit 2 (suggested-prompts) | 212 | 1712 (+9) |
| After commit 3 (daily-briefing) | 213 | 1730 (+18) | | After commit 3 (daily-briefing) | 213 | 1730 (+18) |
| After commit 4 (hero-strip) | 214 | 1752 (+22) | | After commit 4 (hero-strip) | 214 | 1752 (+22) |
| After commit 5 (page wire-up) | 214 | 1753 (+1 polish guard) | | After commit 5 (page wire-up) | 214 | 1753 (+1 polish guard) |
Net delta: **+4 test files, +81 tests**, all green. `pnpm typecheck` Net delta: **+4 test files, +81 tests**, all green. `pnpm typecheck`
clean across the chain. `pnpm lint` baseline preserved (12 pre- clean across the chain. `pnpm lint` baseline preserved (12 pre-
@@ -118,7 +119,7 @@ defer rather than a block — see above for the reasoning.
5. **5 default suggested prompts.** Pinned in i18n 5. **5 default suggested prompts.** Pinned in i18n
(`whyMonday / weightVsPulse / weekVsMonth / tellMyDoctor / (`whyMonday / weightVsPulse / weekVsMonth / tellMyDoctor /
medicationWorking`). The list mirrors the artboard verbatim; if medicationWorking`). The list mirrors the artboard verbatim; if
"What should I tell my doctor?" reads too clinical, flipping "What should I tell my doctor?" reads too clinical, flipping
the EN value is a one-line edit. the EN value is a one-line edit.
@@ -134,6 +135,7 @@ i18n-locale-integrity ✓ EN + DE share the same key shape
## Files added / modified ## Files added / modified
**Added:** **Added:**
- `src/components/insights/hero-strip.tsx` - `src/components/insights/hero-strip.tsx`
- `src/components/insights/daily-briefing.tsx` - `src/components/insights/daily-briefing.tsx`
- `src/components/insights/suggested-prompts.tsx` - `src/components/insights/suggested-prompts.tsx`
@@ -143,6 +145,7 @@ i18n-locale-integrity ✓ EN + DE share the same key shape
- `src/lib/ai/__tests__/daily-briefing-schema.test.ts` - `src/lib/ai/__tests__/daily-briefing-schema.test.ts`
**Modified:** **Modified:**
- `src/lib/ai/schema.ts` — `dailyBriefing` block added to response schema - `src/lib/ai/schema.ts` — `dailyBriefing` block added to response schema
- `src/lib/ai/prompts/insight-generator.ts` — PROMPT_VERSION 4.20.0, GROUND RULE 8, JSON-shape examples in EN + DE - `src/lib/ai/prompts/insight-generator.ts` — PROMPT_VERSION 4.20.0, GROUND RULE 8, JSON-shape examples in EN + DE
- `src/components/insights/use-insights-advisor.ts` — `dailyBriefing` lifted off cached payload - `src/components/insights/use-insights-advisor.ts` — `dailyBriefing` lifted off cached payload
+13 -13
View File
@@ -11,13 +11,13 @@ endpoints. The drawer UI (B2b) dispatches separately.
## Commit timeline (five atomic commits on `develop`) ## Commit timeline (five atomic commits on `develop`)
| SHA | Subject | | SHA | Subject |
| --- | --- | | --------- | --------------------------------------------------------------------- |
| `5527001` | `feat(coach): add CoachConversation, CoachMessage, CoachUsage models` | | `5527001` | `feat(coach): add CoachConversation, CoachMessage, CoachUsage models` |
| `a68557d` | `feat(coach): types, persistence, budget, and refusal helpers` | | `a68557d` | `feat(coach): types, persistence, budget, and refusal helpers` |
| `16f3ece` | `feat(coach): add POST /api/insights/chat SSE streaming endpoint` | | `16f3ece` | `feat(coach): add POST /api/insights/chat SSE streaming endpoint` |
| `3168ba8` | `feat(coach): GET list, GET single, DELETE conversation endpoints` | | `3168ba8` | `feat(coach): GET list, GET single, DELETE conversation endpoints` |
| _(this)_ | `docs(planning): tick B2a complete, record phase-B2a report` | | _(this)_ | `docs(planning): tick B2a complete, record phase-B2a report` |
## Schema + migration ## Schema + migration
@@ -49,9 +49,9 @@ every Coach row.
read helpers, `summariseTitle()` for the rail, cursor-paginated read helpers, `summariseTitle()` for the rail, cursor-paginated
`listConversations()`, `deleteConversation()` with ownership check. `listConversations()`, `deleteConversation()` with ownership check.
- `src/lib/ai/coach/budget.ts` — `MAX_TOKENS_PER_USER_PER_DAY = 25 - `src/lib/ai/coach/budget.ts` — `MAX_TOKENS_PER_USER_PER_DAY = 25
000`. UTC `dateKey` so the meter aligns with the LLM provider's 000`. UTC `dateKey` so the meter aligns with the LLM provider's
billing boundary. `enforceBudget()` throws `HttpError(429, billing boundary. `enforceBudget()` throws `HttpError(429,
"coach.budget.exceeded")`. `recordSpend()` clamps non-finite token "coach.budget.exceeded")`. `recordSpend()` clamps non-finite token
figures to 0 so a misbehaving provider can't poison the ledger. figures to 0 so a misbehaving provider can't poison the ledger.
- `src/lib/ai/coach/refusal.ts` — pattern-based detector for prompt - `src/lib/ai/coach/refusal.ts` — pattern-based detector for prompt
injection (EN + DE wording, jailbreak variants, "reveal your injection (EN + DE wording, jailbreak variants, "reveal your
@@ -87,10 +87,10 @@ every Coach row.
## Test coverage ## Test coverage
| Suite | Before | After | Δ | | Suite | Before | After | Δ |
| --- | --- | --- | --- | | ------------- | ------ | ----- | --- |
| Unit (vitest) | 1753 | 1781 | +28 | | Unit (vitest) | 1753 | 1781 | +28 |
| Integration | 67 | 78 | +11 | | Integration | 67 | 78 | +11 |
Unit tests cover refusal patterns (13 cases), title summarisation (6), Unit tests cover refusal patterns (13 cases), title summarisation (6),
and budget guard semantics (9). Integration tests pin five chat-route and budget guard semantics (9). Integration tests pin five chat-route
@@ -131,7 +131,7 @@ without the encryption key for future analytics.
- The "summarise older half" pass is currently a placeholder (the - The "summarise older half" pass is currently a placeholder (the
history-window builder injects a synthetic `[summary placeholder history-window builder injects a synthetic `[summary placeholder
— N earlier turns elided]` line rather than calling a provider to — N earlier turns elided]` line rather than calling a provider to
produce a real summary). Cheap and deterministic; an opt-in real- produce a real summary). Cheap and deterministic; an opt-in real-
summarisation pass can land alongside B2b once the drawer UI shows summarisation pass can land alongside B2b once the drawer UI shows
whether users actually push past 20 turns. whether users actually push past 20 turns.
+12 -12
View File
@@ -12,13 +12,13 @@ strip's "Ask the coach" button + suggested-prompt chips.
## Commit timeline (four atomic commits + this report on `develop`) ## Commit timeline (four atomic commits + this report on `develop`)
| SHA | Subject | | SHA | Subject |
| --- | --- | | --------- | ----------------------------------------------------------------- |
| `c42c0a5` | `feat(coach): TanStack Query hooks + SSE streaming client` | | `c42c0a5` | `feat(coach): TanStack Query hooks + SSE streaming client` |
| `195448a` | `feat(coach): drawer shell, message thread, source chips` | | `195448a` | `feat(coach): drawer shell, message thread, source chips` |
| `8dac389` | `feat(coach): composer, history rail, sources rail` | | `8dac389` | `feat(coach): composer, history rail, sources rail` |
| `e541799` | `feat(insights): wire Coach drawer into /insights via hero strip` | | `e541799` | `feat(insights): wire Coach drawer into /insights via hero strip` |
| _(this)_ | `docs(planning): tick B2b complete, record phase-B2b report` | | _(this)_ | `docs(planning): tick B2b complete, record phase-B2b report` |
## Streaming implementation ## Streaming implementation
@@ -88,10 +88,10 @@ the defaults so callers only need to pass `open` + `onOpenChange` +
## Test coverage ## Test coverage
| Suite | Before | After | Δ | | Suite | Before | After | Δ |
| --- | --- | --- | --- | | ------------- | ------ | ----- | --- |
| Unit (vitest) | 1781 | 1833 | +52 | | Unit (vitest) | 1781 | 1833 | +52 |
| Integration | 78 | 78 | 0 | | Integration | 78 | 78 | 0 |
Six new test files cover the streaming parser, every drawer column, Six new test files cover the streaming parser, every drawer column,
the message thread state machine, locale-aware labels, and the the message thread state machine, locale-aware labels, and the
@@ -110,8 +110,8 @@ New strings under `insights.coach.*`:
- Sources rail: `sourcesTitle`, `sourcesFresh`, `sourcesStale`, - Sources rail: `sourcesTitle`, `sourcesFresh`, `sourcesStale`,
`sourcesFooter` `sourcesFooter`
- Provenance chip namespaces: `metric.{bp,weight,pulse,mood, - Provenance chip namespaces: `metric.{bp,weight,pulse,mood,
compliance,general}` and `window.{last7days,last30days,last90days, compliance,general}` and `window.{last7days,last30days,last90days,
allTime}` allTime}`
Both `messages/en.json` and `messages/de.json` updated; Both `messages/en.json` and `messages/de.json` updated;
`i18n-locale-integrity.test.ts` keeps passing. `i18n-locale-integrity.test.ts` keeps passing.
+2 -2
View File
@@ -48,7 +48,7 @@ near-zero scatter that would imply a pattern that isn't there.
`<ScatterCorrelationChart>` wrapper), confidence chip, source-chip `<ScatterCorrelationChart>` wrapper), confidence chip, source-chip
("based on N paired readings · last 30 days"), interpretation ("based on N paired readings · last 30 days"), interpretation
prose, disabled "Try a 7-day experiment" CTA with `title="Coming prose, disabled "Try a 7-day experiment" CTA with `title="Coming
soon"`. Row mounts the three cards in 2-up grid on `>=md` and soon"`. Row mounts the three cards in 2-up grid on `>=md` and
single column on `<md`; the correlation-disclaimer footer single column on `<md`; the correlation-disclaimer footer
("Patterns are observational, not causal — talk to your doctor if ("Patterns are observational, not causal — talk to your doctor if
BP runs high.") sits below the row once. 13 component tests. BP runs high.") sits below the row once. 13 component tests.
@@ -127,7 +127,7 @@ near-zero scatter that would imply a pattern that isn't there.
`__tests__/correlations.test.ts`) `__tests__/correlations.test.ts`)
- `src/lib/ai/schema.ts` — `trendAnnotationsSchema` + integration - `src/lib/ai/schema.ts` — `trendAnnotationsSchema` + integration
- `src/lib/ai/prompts/insight-generator.ts` — PROMPT_VERSION 4.20.1 - `src/lib/ai/prompts/insight-generator.ts` — PROMPT_VERSION 4.20.1
+ GROUND RULE 9 (EN + DE) + JSON shape entry - GROUND RULE 9 (EN + DE) + JSON shape entry
- `src/lib/ai/__tests__/trend-annotations-schema.test.ts` - `src/lib/ai/__tests__/trend-annotations-schema.test.ts`
- `src/components/insights/trend-annotation.tsx` + - `src/components/insights/trend-annotation.tsx` +
`__tests__/trend-annotation.test.tsx` `__tests__/trend-annotation.test.tsx`
+4 -4
View File
@@ -171,8 +171,8 @@ So a curious user can mentally recompute:
followed the strategic-plan spec verbatim. The four-component followed the strategic-plan spec verbatim. The four-component
weighted blend matches the artboard's intent. No silent weighted blend matches the artboard's intent. No silent
reinterpretation; the plan-vs-implementation diff is the formula reinterpretation; the plan-vs-implementation diff is the formula
+ bands + null-redistribution rules, all defensible from first - bands + null-redistribution rules, all defensible from first
principles. principles.
## Flagged-uncertain items (maintainer review) ## Flagged-uncertain items (maintainer review)
@@ -180,8 +180,8 @@ So a curious user can mentally recompute:
Prisma schema declares the column but no migration applies it. Prisma schema declares the column but no migration applies it.
The B5 route worked around it with an explicit `select` clause. The B5 route worked around it with an explicit `select` clause.
The drift exists across the codebase — `src/app/api/insights/ The drift exists across the codebase — `src/app/api/insights/
comprehensive/route.ts` queries via `include: { schedules: true comprehensive/route.ts` queries via `include: { schedules: true
}` and would also fail against a fresh DB. Suggest a v1.4.21 }` and would also fail against a fresh DB. Suggest a v1.4.21
hygiene PR that either deploys the missing column or removes it hygiene PR that either deploys the missing column or removes it
from schema.prisma. from schema.prisma.
2. **Previous-week BP rate.** The current implementation reuses the 2. **Previous-week BP rate.** The current implementation reuses the
+4 -3
View File
@@ -208,9 +208,10 @@ without a `chartKey`. The HealthChart contract treats missing chartKey as
intentional design — but the cog dropdown is therefore invisible on intentional design — but the cog dropdown is therefore invisible on
`/insights` while it IS visible on the dashboard. Marc may rediscover `/insights` while it IS visible on the dashboard. Marc may rediscover
this as an inconsistency. Either: this as an inconsistency. Either:
- document it (the per-chart prefs are dashboard-only by design), OR
- mount overlay controls on `/insights` too (would require new - document it (the per-chart prefs are dashboard-only by design), OR
chart-key entries in the dashboard layout). - mount overlay controls on `/insights` too (would require new
chart-key entries in the dashboard layout).
### M-03 — `formatTokenName` regex doesn't cover non-ISO suffixes ### M-03 — `formatTokenName` regex doesn't cover non-ISO suffixes
+11 -8
View File
@@ -13,18 +13,19 @@ review. Findings keyed to file + line numbers in the working tree.
## Scope ## Scope
Pages audited: Pages audited:
- `/dashboard` (`src/app/page.tsx`) - `/dashboard` (`src/app/page.tsx`)
- `/insights` (`src/app/insights/page.tsx`, - `/insights` (`src/app/insights/page.tsx`,
`src/components/insights/insights-page-hero.tsx`) `src/components/insights/insights-page-hero.tsx`)
- Settings shell — `/settings/{account,profile,dashboard,ai, - Settings shell — `/settings/{account,profile,dashboard,ai,
notifications,integrations,export}` notifications,integrations,export}`
(`src/components/settings/*.tsx`) (`src/components/settings/*.tsx`)
- `/admin/feedback`, `/admin/api-tokens` - `/admin/feedback`, `/admin/api-tokens`
(`src/components/admin/{feedback-inbox-section, (`src/components/admin/{feedback-inbox-section,
api-token-overview-section}.tsx`) api-token-overview-section}.tsx`)
- Zielwerte (`src/app/targets/page.tsx`) - Zielwerte (`src/app/targets/page.tsx`)
- Charts (`src/components/charts/{health,mood, - Charts (`src/components/charts/{health,mood,
medication-compliance}-chart.tsx`, medication-compliance}-chart.tsx`,
`src/components/charts/chart-overlay-controls.tsx`) `src/components/charts/chart-overlay-controls.tsx`)
- Comparison toggle (`src/components/comparison/compare-toggle.tsx`) - Comparison toggle (`src/components/comparison/compare-toggle.tsx`)
@@ -93,7 +94,7 @@ Pages audited:
- **Severity:** HIGH - **Severity:** HIGH
- **File:** `src/components/admin/api-token-overview-section.tsx:37-56` - **File:** `src/components/admin/api-token-overview-section.tsx:37-56`
+ `src/components/ui/tooltip.tsx:1-58` - `src/components/ui/tooltip.tsx:1-58`
- **Issue:** The 4th-attempt truncate-with-tooltip pattern wraps long - **Issue:** The 4th-attempt truncate-with-tooltip pattern wraps long
token names / usernames / permission badges in a Radix token names / usernames / permission badges in a Radix
`<Tooltip>` plus a native `title=` attribute. Neither surfaces `<Tooltip>` plus a native `title=` attribute. Neither surfaces
@@ -117,6 +118,7 @@ Pages audited:
truncated api-tokens cells works on touch (long-press on mobile, truncated api-tokens cells works on touch (long-press on mobile,
hover on desktop)". The hover path works; the touch path does hover on desktop)". The hover path works; the touch path does
not. not.
- **Recommendation:** On the mobile card-list (`md:hidden`, line - **Recommendation:** On the mobile card-list (`md:hidden`, line
232), drop `truncate` and let long values wrap to two lines — 232), drop `truncate` and let long values wrap to two lines —
vertical space is cheap on a card and reading is more important vertical space is cheap on a card and reading is more important
@@ -133,7 +135,7 @@ Pages audited:
- **Issue:** When the comparison toggle (`metaSlot`) is mounted AND - **Issue:** When the comparison toggle (`metaSlot`) is mounted AND
the regenerate button is wired (the live `/insights` config), the the regenerate button is wired (the live `/insights` config), the
outer flex `flex-col gap-4 sm:flex-row sm:items-start outer flex `flex-col gap-4 sm:flex-row sm:items-start
sm:justify-between` puts the regenerate button in a 2nd column on sm:justify-between` puts the regenerate button in a 2nd column on
desktop; on mobile (`<sm`) it stacks below the meta-slot. The desktop; on mobile (`<sm`) it stacks below the meta-slot. The
meta-slot itself wraps the `<CompareToggle>` (3 buttons, each meta-slot itself wraps the `<CompareToggle>` (3 buttons, each
`min-h-11 px-3`). `min-h-11 px-3`).
@@ -149,6 +151,7 @@ Pages audited:
before any card body. Marc's A3 concern was "three stacked cards before any card body. Marc's A3 concern was "three stacked cards
with separate controls" feel; folding the toggle into the hero with separate controls" feel; folding the toggle into the hero
removed the standalone band but made the hero itself heavy. removed the standalone band but made the hero itself heavy.
- **Recommendation:** On `>=sm`, inline the CompareToggle next to - **Recommendation:** On `>=sm`, inline the CompareToggle next to
the Regenerate button (both in the right-side column) instead of the Regenerate button (both in the right-side column) instead of
inside the title block. On `<sm`, keep the current stacked layout inside the title block. On `<sm`, keep the current stacked layout
@@ -164,7 +167,7 @@ Pages audited:
- **Severity:** MED - **Severity:** MED
- **File:** `src/app/admin/[section]/renderer.tsx:123-131,180-192` - **File:** `src/app/admin/[section]/renderer.tsx:123-131,180-192`
+ `src/components/admin/api-token-overview-section.tsx:101-106` - `src/components/admin/api-token-overview-section.tsx:101-106`
- **Issue:** `SectionFrame` renders `<h1>API Tokens</h1>` - **Issue:** `SectionFrame` renders `<h1>API Tokens</h1>`
(renderer line 184) AND `<ApiTokenOverviewSection>` body renders (renderer line 184) AND `<ApiTokenOverviewSection>` body renders
`<div class="text-lg font-semibold">API Tokens</div>` (section `<div class="text-lg font-semibold">API Tokens</div>` (section
@@ -190,7 +193,7 @@ Pages audited:
is 1-column (correct), but on desktop the right cell renders is 1-column (correct), but on desktop the right cell renders
empty without a placeholder — visually fine, semantically odd. empty without a placeholder — visually fine, semantically odd.
- **Recommendation:** Replace the grid with a plain `<div - **Recommendation:** Replace the grid with a plain `<div
class="sm:max-w-md">` so the DOB input still caps at half-card class="sm:max-w-md">` so the DOB input still caps at half-card
width on desktop. Drops one DOM node + one CSS class. width on desktop. Drops one DOM node + one CSS class.
- **Ship-blocker?** No. - **Ship-blocker?** No.
@@ -273,7 +276,7 @@ Pages audited:
- **Severity:** LOW - **Severity:** LOW
- **File:** `src/components/admin/api-token-overview-section.tsx:135-142, - **File:** `src/components/admin/api-token-overview-section.tsx:135-142,
213-220` 213-220`
- **Issue:** The colgroup widths sum to `18+28+24+10+12+8 = 100 %`. - **Issue:** The colgroup widths sum to `18+28+24+10+12+8 = 100 %`.
`lastUsedAt` cell is `whitespace-nowrap` rendering `lastUsedAt` cell is `whitespace-nowrap` rendering
`formatDateTime(...)` (e.g. "10.05.2026, 13:18:42" — 19 chars + `formatDateTime(...)` (e.g. "10.05.2026, 13:18:42" — 19 chars +
+12 -12
View File
@@ -16,18 +16,18 @@ Agent: Phase D RECONCILE
## HIGH triage ## HIGH triage
| ID | Source | Decision | Commit / Backlog ref | | ID | Source | Decision | Commit / Backlog ref |
|---|---|---|---| | ---------------- | -------------------------------------------- | ------------------------- | ---------------------------- |
| H-01 (code) | UTC clock breaks Berlin convention | **fixed** | `1258b24` | | H-01 (code) | UTC clock breaks Berlin convention | **fixed** | `1258b24` |
| H-02 (code) | useViewportWidth setState in effect | **fixed** | `5a8ad3d` | | H-02 (code) | useViewportWidth setState in effect | **fixed** | `5a8ad3d` |
| H-03 (code) | Orphan icon wrapper after card-title removal | **fixed via simplify F4** | `6b35cad` | | H-03 (code) | Orphan icon wrapper after card-title removal | **fixed via simplify F4** | `6b35cad` |
| H-04 (code) | Tabs ring clip on focus-visible | **fixed** | `977f124` | | H-04 (code) | Tabs ring clip on focus-visible | **fixed** | `977f124` |
| H-05 (code) | `/insights` `data?.` post-narrowing | deferred (D-CR-H-05) | v1.4.20 backlog | | H-05 (code) | `/insights` `data?.` post-narrowing | deferred (D-CR-H-05) | v1.4.20 backlog |
| H-01 (design) | api-tokens truncate-tooltip on touch | deferred (D-DSGN-H-01) | v1.4.20 backlog | | H-01 (design) | api-tokens truncate-tooltip on touch | deferred (D-DSGN-H-01) | v1.4.20 backlog |
| H-02 (design) | Insights hero density on Pixel-5 | deferred (D-DSGN-H-02) | folded into v1.4.20 redesign | | H-02 (design) | Insights hero density on Pixel-5 | deferred (D-DSGN-H-02) | folded into v1.4.20 redesign |
| H-1 (senior-dev) | DateInput wrapper | **fixed via simplify F3** | `6b35cad` | | H-1 (senior-dev) | DateInput wrapper | **fixed via simplify F3** | `6b35cad` |
| H-2 (senior-dev) | useAuthActionLabels lift-out | **fixed via simplify F1** | `6b35cad` | | H-2 (senior-dev) | useAuthActionLabels lift-out | **fixed via simplify F1** | `6b35cad` |
| H-3 (senior-dev) | Withings/MoodLog card chrome | deferred (D-SR-H-3) | v1.4.20 backlog | | H-3 (senior-dev) | Withings/MoodLog card chrome | deferred (D-SR-H-3) | v1.4.20 backlog |
Tally: **7 HIGH fixed, 3 HIGH deferred**. Tally: **7 HIGH fixed, 3 HIGH deferred**.
+16 -10
View File
@@ -7,12 +7,13 @@ Method: Read CLAUDE.md, .planning/STATE.md, full diff via
`git log --oneline v1.4.18...HEAD` + targeted `git diff` per file, `git log --oneline v1.4.18...HEAD` + targeted `git diff` per file,
plus reads of every changed source file in the focus list, plus plus reads of every changed source file in the focus list, plus
cross-checks of: cross-checks of:
- the prompt revision against the existing server-side n<3
confidence clamp (`src/lib/ai/confidence.ts`) - the prompt revision against the existing server-side n<3
- the integration error decryption path (unchanged from v1.4.18) confidence clamp (`src/lib/ai/confidence.ts`)
- the admin tokens API selector (no `tokenHash`) - the integration error decryption path (unchanged from v1.4.18)
- new audit-log labels' semantics - the admin tokens API selector (no `tokenHash`)
- i18n placeholder interpolation (`{count}`, integer-only) - new audit-log labels' semantics
- i18n placeholder interpolation (`{count}`, integer-only)
(Previous v1.4.18 review at `phase-D-v1418-security-findings.md` was (Previous v1.4.18 review at `phase-D-v1418-security-findings.md` was
overwritten — the v1.4.18 release shipped, those findings already overwritten — the v1.4.18 release shipped, those findings already
@@ -97,11 +98,11 @@ filed in `.planning/v1419-backlog.md` / `.planning/v15-backlog.md`.)
- **Severity**: LOW (existing v1.4.18 behaviour, surfaced by A5 refactor) - **Severity**: LOW (existing v1.4.18 behaviour, surfaced by A5 refactor)
- **File**: `src/components/settings/integrations-section.tsx:332-333, - **File**: `src/components/settings/integrations-section.tsx:332-333,
354, 630-631, 663` (`<IntegrationErrorMessage>`) 354, 630-631, 663` (`<IntegrationErrorMessage>`)
- **Issue**: A5 consolidated the status UI but kept the inline - **Issue**: A5 consolidated the status UI but kept the inline
actionable error message under the pill. `viewModel.lastError` is actionable error message under the pill. `viewModel.lastError` is
decrypted by the API in `src/lib/integrations/status.ts:112, decrypted by the API in `src/lib/integrations/status.ts:112,
376-383` from the `IntegrationStatus.lastError` ciphertext. The 376-383` from the `IntegrationStatus.lastError` ciphertext. The
message is bounded to 1024 chars at encrypt time message is bounded to 1024 chars at encrypt time
(`status.ts:367`) and originates from controlled callsites in (`status.ts:367`) and originates from controlled callsites in
`src/lib/withings/sync.ts` + `src/lib/moodlog/sync.ts` — neither `src/lib/withings/sync.ts` + `src/lib/moodlog/sync.ts` — neither
@@ -118,7 +119,7 @@ filed in `.planning/v1419-backlog.md` / `.planning/v15-backlog.md`.)
a token, never an Authorization header. Withings/moodLog OAuth a token, never an Authorization header. Withings/moodLog OAuth
error responses do not contain refresh tokens. error responses do not contain refresh tokens.
- Pill viewer is the message owner only — `/api/integrations/ - Pill viewer is the message owner only — `/api/integrations/
status` is `requireAuth`-gated and returns the calling user's status` is `requireAuth`-gated and returns the calling user's
own row. own row.
- **Recommendation**: None for v1.4.19. If a future v1.5 sync - **Recommendation**: None for v1.4.19. If a future v1.5 sync
helper passes raw HTTP body into `recordSyncFailure`, that's the helper passes raw HTTP body into `recordSyncFailure`, that's the
@@ -172,6 +173,7 @@ purely Tailwind class swaps on `<select>` / button trigger elements
or any masking logic. or any masking logic.
### Q4 — A7 feedback scrollbar fix drops scroll-confinement that ### Q4 — A7 feedback scrollbar fix drops scroll-confinement that
prevents action-overflow? prevents action-overflow?
**No.** `src/components/ui/tabs.tsx` adds `overflow-y-hidden` and a **No.** `src/components/ui/tabs.tsx` adds `overflow-y-hidden` and a
@@ -194,6 +196,7 @@ in the DB — only HMAC-SHA-256 of the value. Even if the API leaked
the hash, it's not a usable secret. the hash, it's not a usable secret.
### Q5 — A1 BD-Zielbereich percentages exposed via API/audit-log to ### Q5 — A1 BD-Zielbereich percentages exposed via API/audit-log to
another user? another user?
**No.** `/api/analytics` uses `requireAuth()` and scopes every **No.** `/api/analytics` uses `requireAuth()` and scopes every
@@ -206,17 +209,20 @@ sample counts, no measurements ever land in the audit log.
No cross-user pathway exists. No cross-user pathway exists.
### Q6 — Wave-B 27 fixes introduce dangerouslySetInnerHTML / new ### Q6 — Wave-B 27 fixes introduce dangerouslySetInnerHTML / new
endpoint / new storage? endpoint / new storage?
**No.** **No.**
- `git diff v1.4.18...HEAD | grep dangerouslySetInnerHTML` → empty. - `git diff v1.4.18...HEAD | grep dangerouslySetInnerHTML` → empty.
- `git diff --name-status v1.4.18...HEAD | grep "^A" | grep - `git diff --name-status v1.4.18...HEAD | grep "^A" | grep
"src/app/api"` → empty (zero new API routes). "src/app/api"` → empty (zero new API routes).
- Zero new Prisma models, zero new env vars consumed. - Zero new Prisma models, zero new env vars consumed.
- The Wave-B sweep is i18n-string + `t()`-substitution + minor JSX - The Wave-B sweep is i18n-string + `t()`-substitution + minor JSX
restructuring (drop duplicate card titles, link audit rows, etc.). restructuring (drop duplicate card titles, link audit rows, etc.).
### Q7 — General hygiene: logging respects redactSecrets, new routes ### Q7 — General hygiene: logging respects redactSecrets, new routes
use apiHandler, new i18n keys can't pull arbitrary user input? use apiHandler, new i18n keys can't pull arbitrary user input?
- **Logging**: zero new `annotate()` / `getEvent()` calls in the - **Logging**: zero new `annotate()` / `getEvent()` calls in the
+23 -23
View File
@@ -50,14 +50,14 @@ noted but is not v1.4.19's regression.
of HEAD, `grep -rn 'lang={locale}'` returns exactly 14 hits and of HEAD, `grep -rn 'lang={locale}'` returns exactly 14 hits and
every `<Input type="date">` / `<Input type="datetime-local">` every `<Input type="date">` / `<Input type="datetime-local">`
callsite carries it (verified via `grep -c callsite carries it (verified via `grep -c
'type="date(time-local)?"`). Future date inputs added without 'type="date(time-local)?"`). Future date inputs added without
the prop will silently regress. the prop will silently regress.
- **Recommendation:** Introduce a thin wrapper - **Recommendation:** Introduce a thin wrapper
`src/components/ui/date-input.tsx` (or `localized-date-input.tsx`) `src/components/ui/date-input.tsx` (or `localized-date-input.tsx`)
that wraps `<Input>` + reads `useTranslations()` internally to that wraps `<Input>` + reads `useTranslations()` internally to
set `lang`. Replaces every `<Input type="date" lang={locale} … />` set `lang`. Replaces every `<Input type="date" lang={locale} … />`
with `<DateInput … />` and `<Input type="datetime-local" with `<DateInput … />` and `<Input type="datetime-local"
lang={locale} … />` with `<DateTimeInput … />`. Eliminates the lang={locale} … />` with `<DateTimeInput … />`. Eliminates the
14× duplication and the parent-component `locale` import noise. 14× duplication and the parent-component `locale` import noise.
- **Ship-blocker?** No. The pattern works correctly today; the - **Ship-blocker?** No. The pattern works correctly today; the
refactor is hygiene. Recommend follow-up commit before v1.5 refactor is hygiene. Recommend follow-up commit before v1.5
@@ -96,14 +96,14 @@ noted but is not v1.4.19's regression.
- **File:** `src/components/settings/integrations-section.tsx` - **File:** `src/components/settings/integrations-section.tsx`
(`WithingsCard` lines 199-531, `MoodLogCard` lines 533-831) (`WithingsCard` lines 199-531, `MoodLogCard` lines 533-831)
- **Issue:** A5 successfully introduced `IntegrationStatusPill` for - **Issue:** A5 successfully introduced `IntegrationStatusPill` for
the status chip, but the *card chrome* itself the status chip, but the _card chrome_ itself
(`<div className="bg-card border-border rounded-xl border p-6">` (`<div className="bg-card border-border rounded-xl border p-6">`
→ header row with icon + title + pill → `<p>` muted description → header row with icon + title + pill → `<p>` muted description
→ `<hr data-testid="integration-card-divider" → `<hr data-testid="integration-card-divider"
className="border-border/60 mt-4">` → `<div className="mt-4 className="border-border/60 mt-4">` → `<div className="mt-4
space-y-4">` body) is duplicated verbatim between WithingsCard space-y-4">` body) is duplicated verbatim between WithingsCard
and MoodLogCard. The Phase A5 report explicitly mentions and MoodLogCard. The Phase A5 report explicitly mentions
*"Reusable for v1.4.20 Apple Health card on iOS"* — but the _"Reusable for v1.4.20 Apple Health card on iOS"_ — but the
reusable surface today is only the pill, not the card. Adding reusable surface today is only the pill, not the card. Adding
Apple Health will paste the same chrome a third time. Apple Health will paste the same chrome a third time.
- **Recommendation:** Extract `<IntegrationCard>` co-located with - **Recommendation:** Extract `<IntegrationCard>` co-located with
@@ -116,9 +116,9 @@ noted but is not v1.4.19's regression.
- `errorMessage?: string` - `errorMessage?: string`
- `children` (the unique card body — credentials form, sync - `children` (the unique card body — credentials form, sync
button, etc.) button, etc.)
Both existing cards become a thin shell + their unique body Both existing cards become a thin shell + their unique body
forms. Apple Health card v1.5 ships as ~30 lines of unique forms. Apple Health card v1.5 ships as ~30 lines of unique
form, not ~200 lines of mostly chrome. form, not ~200 lines of mostly chrome.
- **Ship-blocker?** No. Defer to v1.4.20 or v1.5 (would touch - **Ship-blocker?** No. Defer to v1.4.20 or v1.5 (would touch
v1.4.19's tested surface, not worth the risk pre-release). v1.4.19's tested surface, not worth the risk pre-release).
@@ -135,7 +135,7 @@ noted but is not v1.4.19's regression.
- `src/components/charts/medication-compliance-chart.tsx:269` - `src/components/charts/medication-compliance-chart.tsx:269`
- **Issue:** A2 introduced an identical mobile-first header - **Issue:** A2 introduced an identical mobile-first header
layout (`flex flex-col gap-2 sm:flex-row sm:items-center layout (`flex flex-col gap-2 sm:flex-row sm:items-center
sm:justify-between`) plus the matching chip-hide rules sm:justify-between`) plus the matching chip-hide rules
(`hidden sm:inline-flex` on bucket chip + comparison chip) in (`hidden sm:inline-flex` on bucket chip + comparison chip) in
three chart components. The same 4× `<Button>` time-range tab three chart components. The same 4× `<Button>` time-range tab
loop on `TIME_RANGES_KEYS` is duplicated between health-chart loop on `TIME_RANGES_KEYS` is duplicated between health-chart
@@ -185,11 +185,11 @@ noted but is not v1.4.19's regression.
(`useAuthActionLabels()` returning a memoised value). (`useAuthActionLabels()` returning a memoised value).
- **Ship-blocker?** No. - **Ship-blocker?** No.
### M-4 — F-02 "auth.* filter" rule expressed in two places ### M-4 — F-02 "auth.\* filter" rule expressed in two places
- **Severity:** MEDIUM - **Severity:** MEDIUM
- **File:** `src/components/admin/login-overview-section.tsx:96 - **File:** `src/components/admin/login-overview-section.tsx:96
+ 256` - 256`
- **Issue:** F-02's "filter to `auth.*` actions" rule is - **Issue:** F-02's "filter to `auth.*` actions" rule is
expressed in two places: (1) expressed in two places: (1)
`params.set("filter", "auth")` on the API query at line 96, and `params.set("filter", "auth")` on the API query at line 96, and
@@ -245,8 +245,8 @@ noted but is not v1.4.19's regression.
a callsite. a callsite.
- **Recommendation:** Defer to v1.4.20 or v1.5. Split into - **Recommendation:** Defer to v1.4.20 or v1.5. Split into
`ai-section/{index, active-provider-select, `ai-section/{index, active-provider-select,
provider-config-card, forms/{codex, openai, anthropic, local, provider-config-card, forms/{codex, openai, anthropic, local,
admin-openai}, fallback-chain-card, runtime-actions-row}.tsx`. admin-openai}, fallback-chain-card, runtime-actions-row}.tsx`.
Mirrors how `src/components/admin/` is structured (one section Mirrors how `src/components/admin/` is structured (one section
per file). per file).
- **Ship-blocker?** No. - **Ship-blocker?** No.
@@ -265,7 +265,7 @@ noted but is not v1.4.19's regression.
own file. own file.
- **Recommendation:** Same as H-3 — under - **Recommendation:** Same as H-3 — under
`src/components/settings/integrations/{index, withings-card, `src/components/settings/integrations/{index, withings-card,
mood-log-card, integration-card}.tsx`. mood-log-card, integration-card}.tsx`.
- **Ship-blocker?** No. - **Ship-blocker?** No.
### L-3 — `health-chart.tsx` is 1360 lines ### L-3 — `health-chart.tsx` is 1360 lines
@@ -286,7 +286,7 @@ noted but is not v1.4.19's regression.
- **Issue:** Uses `getUTCDate()` / `getUTCMonth()` / `getUTCHours()` - **Issue:** Uses `getUTCDate()` / `getUTCMonth()` / `getUTCHours()`
to render the embedded ISO timestamp on auto-issued token to render the embedded ISO timestamp on auto-issued token
names (`web auto-login 2026-05-05T19:46:20.603Z` → `web names (`web auto-login 2026-05-05T19:46:20.603Z` → `web
auto-login · 05.05.2026 19:46`). Marc's project default is auto-login · 05.05.2026 19:46`). Marc's project default is
Europe/Berlin display, UTC storage. A token issued at 19:46 Europe/Berlin display, UTC storage. A token issued at 19:46
Berlin in winter renders as 18:46. Other admin surfaces use Berlin in winter renders as 18:46. Other admin surfaces use
`formatDate()` / `formatDateTime()` from `@/lib/format`, which `formatDate()` / `formatDateTime()` from `@/lib/format`, which
@@ -295,9 +295,9 @@ noted but is not v1.4.19's regression.
`format.ts` helpers, with different output rules `format.ts` helpers, with different output rules
(DD.MM.YYYY HH:MM hard-coded German order). (DD.MM.YYYY HH:MM hard-coded German order).
- **Recommendation:** Pass the parsed `Date` through `formatDate()` - **Recommendation:** Pass the parsed `Date` through `formatDate()`
+ `formatDateTime()` from `@/lib/format`, or extract a - `formatDateTime()` from `@/lib/format`, or extract a
`formatBerlinDateTimeShort()` shared helper. Hand the `formatBerlinDateTimeShort()` shared helper. Hand the
correctness call to code-reviewer. correctness call to code-reviewer.
- **Ship-blocker?** No. - **Ship-blocker?** No.
### L-5 — Telegram badge collapse uses bullet-point string concat instead of single i18n key OR pill ### L-5 — Telegram badge collapse uses bullet-point string concat instead of single i18n key OR pill
@@ -345,7 +345,7 @@ noted but is not v1.4.19's regression.
(`{ pct: number; pairs: number } | null`). All three semantics (`{ pct: number; pairs: number } | null`). All three semantics
are documented in the function header. The analytics route are documented in the function header. The analytics route
mapping (`bpInTargetPct ← allTime`, `bpInTargetPct7d ← mapping (`bpInTargetPct ← allTime`, `bpInTargetPct7d ←
last7Days`, `bpInTargetPct30d ← last30Days`) is unambiguous last7Days`, `bpInTargetPct30d ← last30Days`) is unambiguous
and tested. and tested.
### L-8 — A7's "AdminShell tweak" was NOT generalized — local fix only ### L-8 — A7's "AdminShell tweak" was NOT generalized — local fix only
@@ -414,7 +414,7 @@ production code stays clean.
also consume it. **NOT premature** — the brief explicitly also consume it. **NOT premature** — the brief explicitly
builds for the third future caller. The premature abstraction builds for the third future caller. The premature abstraction
here would be the OPPOSITE direction: see H-3, where the here would be the OPPOSITE direction: see H-3, where the
*card chrome around the pill* should also have been _card chrome around the pill_ should also have been
extracted — but a tactically-narrow extraction (just the extracted — but a tactically-narrow extraction (just the
pill) is justifiable for v1.4.19's scope. pill) is justifiable for v1.4.19's scope.
@@ -423,7 +423,7 @@ production code stays clean.
documentation comment ("the shared 36-px input contract used documentation comment ("the shared 36-px input contract used
everywhere else in Settings") rather than a CSS variable or everywhere else in Settings") rather than a CSS variable or
shared class string. The `NATIVE_SELECT_CLASS` constant shared class string. The `NATIVE_SELECT_CLASS` constant
introduced in account-section centralises the *native select* introduced in account-section centralises the _native select_
styling (12 visual tokens) — but only within that file. AI styling (12 visual tokens) — but only within that file. AI
section's three `<select>` elements were converted to section's three `<select>` elements were converted to
`h-9` inline. So: the **rule** is single-source (everything is `h-9` inline. So: the **rule** is single-source (everything is
@@ -447,7 +447,7 @@ production code stays clean.
IntegrationStatusPill). No defensive null-checks introduced IntegrationStatusPill). No defensive null-checks introduced
(verified: zero new `?? null` / `?.` patterns wrapping (verified: zero new `?? null` / `?.` patterns wrapping
values that are typed non-nullable). Translation key additions values that are typed non-nullable). Translation key additions
(auth.token.* ×3) correctly land in both EN+DE per the project (auth.token.\* ×3) correctly land in both EN+DE per the project
i18n integrity rule. i18n integrity rule.
- **A1 BP-status windows (Q6):** Verified coherent. See L-7. - **A1 BP-status windows (Q6):** Verified coherent. See L-7.
+7 -4
View File
@@ -75,6 +75,7 @@ is mechanical enough to land without fresh design discussion.
--- ---
## F3 — `lang={locale}` on every `<Input type="datetime-local">` / ## F3 — `lang={locale}` on every `<Input type="datetime-local">` /
`type="date"` — 14 callsites, no shared wrapper `type="date"` — 14 callsites, no shared wrapper
- **Files (count: 14 callsites across 9 files)**: - **Files (count: 14 callsites across 9 files)**:
@@ -127,7 +128,7 @@ is mechanical enough to land without fresh design discussion.
- **Type**: Dead code (post-removal scaffolding) - **Type**: Dead code (post-removal scaffolding)
- **Why it's a smell**: Each of the three commits that dropped a - **Why it's a smell**: Each of the three commits that dropped a
duplicate card title kept the `<div className="flex items-center duplicate card title kept the `<div className="flex items-center
gap-2">` wrapper, which now contains exactly one icon. The wrapper gap-2">` wrapper, which now contains exactly one icon. The wrapper
was there to align icon+title on the same row; with the title gone was there to align icon+title on the same row; with the title gone
the flex/gap classes do nothing. Reads as scar tissue from the the flex/gap classes do nothing. Reads as scar tissue from the
removal patch. removal patch.
@@ -174,6 +175,7 @@ is mechanical enough to land without fresh design discussion.
--- ---
## F6 — Speculative permissive `STRIP_TOKEN_REGEX` for hypothetical ## F6 — Speculative permissive `STRIP_TOKEN_REGEX` for hypothetical
hallucinations hallucinations
- **File**: `src/lib/insights/chart-tokens.ts:42-55` - **File**: `src/lib/insights/chart-tokens.ts:42-55`
@@ -207,6 +209,7 @@ hallucinations
--- ---
## F7 — `formatBackupType` / `formatMeasurementSource` are ## F7 — `formatBackupType` / `formatMeasurementSource` are
near-clone enum-translate helpers near-clone enum-translate helpers
- **Files**: - **Files**:
@@ -283,9 +286,9 @@ near-clone enum-translate helpers
and routed all three values through the windows helper, so the and routed all three values through the windows helper, so the
redundancy is now resolved. **Clean — no further action.** redundancy is now resolved. **Clean — no further action.**
- **A8 / Wave-B copy fixes** — 27 commits, mostly `messages/{de,en}.json` - **A8 / Wave-B copy fixes** — 27 commits, mostly `messages/{de,en}.json`
+ small per-file edits. Spot-checked five files; no near-duplicate - small per-file edits. Spot-checked five files; no near-duplicate
fixes that should share a helper. The two enum-translate helpers fixes that should share a helper. The two enum-translate helpers
(F7) sit at 3 call sites, below threshold. **Clean.** (F7) sit at 3 call sites, below threshold. **Clean.**
- **A7 admin scrollbar / collapse fixes** — `tabs.tsx` got the - **A7 admin scrollbar / collapse fixes** — `tabs.tsx` got the
`overflow-y-hidden` clamp once + a single regression test. The `overflow-y-hidden` clamp once + a single regression test. The
`api-token-overview-section` collapse-button removal didn't leave `api-token-overview-section` collapse-button removal didn't leave
+12 -8
View File
@@ -108,9 +108,10 @@ existing Dracula tokens, existing `recommendation-card.tsx` /
Recharts setup. The proposal is a **superset** of today's `/insights`: Recharts setup. The proposal is a **superset** of today's `/insights`:
every existing card has a home in the new layout (status → every existing card has a home in the new layout (status →
hero strip; advisor → coach entry; recommendations grid → trends hero strip; advisor → coach entry; recommendations grid → trends
+ correlation row). I am ordering the work as five sequential
phases that can ship behind a feature flag and unlock - correlation row). I am ordering the work as five sequential
independently. phases that can ship behind a feature flag and unlock
independently.
### Phase B1 — Hero strip + Daily Briefing + Suggested-prompts (~3-5 days) ### Phase B1 — Hero strip + Daily Briefing + Suggested-prompts (~3-5 days)
@@ -141,7 +142,8 @@ v1.5 / iOS. The micro-stat tiles need a graceful "not yet
available" state for non-iOS users today — render a dimmed tile available" state for non-iOS users today — render a dimmed tile
with "Connect Apple Health (iOS app)" CTA instead of fake data. with "Connect Apple Health (iOS app)" CTA instead of fake data.
Mood + Resting HR can be sourced from existing data (mood entries Mood + Resting HR can be sourced from existing data (mood entries
+ Withings pulse) so 1-of-3 tiles already work.
- Withings pulse) so 1-of-3 tiles already work.
### Phase B2 — AI Coach panel + /insights/coach + streaming chat (~5-7 days) ### Phase B2 — AI Coach panel + /insights/coach + streaming chat (~5-7 days)
@@ -191,8 +193,9 @@ model CoachMessage {
**Source-chip rendering.** Reuse v1.4.16 `metricSource` provenance — **Source-chip rendering.** Reuse v1.4.16 `metricSource` provenance —
every assistant message renders a row of chips ("based on 14d HRV every assistant message renders a row of chips ("based on 14d HRV
+ 6 BP readings"), click opens the data range used. This is the
"AI shows its work" principle from the handoff README. - 6 BP readings"), click opens the data range used. This is the
"AI shows its work" principle from the handoff README.
**Multi-provider** is already there from v1.4.16 B5b — Coach **Multi-provider** is already there from v1.4.16 B5b — Coach
inherits the cascade. inherits the cascade.
@@ -267,6 +270,7 @@ The artboard's right-side hero panel: a composite Health Score
(0-100) with sub-components and a "Ask the Coach" button. (0-100) with sub-components and a "Ask the Coach" button.
**Formula.** Composite of four weighted components: **Formula.** Composite of four weighted components:
- BP-in-target-rate (30 %) — the existing v1.4.18 A1 number - BP-in-target-rate (30 %) — the existing v1.4.18 A1 number
- Weight-trend-alignment (20 %) — direction agrees with user's - Weight-trend-alignment (20 %) — direction agrees with user's
goal target goal target
@@ -308,14 +312,14 @@ alongside:
B5e set up storage; the loop is still open).** v1.4.20 has the B5e set up storage; the loop is still open).** v1.4.20 has the
right shape to wire it — Coach conversations + recommendation right shape to wire it — Coach conversations + recommendation
thumbs feed the same aggregator. When a `(severity × thumbs feed the same aggregator. When a `(severity ×
confidence_band)` bucket's helpful-rate drops below 50 %, confidence_band)` bucket's helpful-rate drops below 50 %,
append "OMIT" / "REPHRASE" rules to the next PROMPT_VERSION. append "OMIT" / "REPHRASE" rules to the next PROMPT_VERSION.
Cheap; runs daily; bumps PROMPT_VERSION 4.20.x → 4.20.x+1 Cheap; runs daily; bumps PROMPT_VERSION 4.20.x → 4.20.x+1
automatically. automatically.
4. **MED-block carry from v1.4.19 backlog** (`.planning/v1420-backlog.md`). 4. **MED-block carry from v1.4.19 backlog** (`.planning/v1420-backlog.md`).
31 MED + 16 LOW; F-36 (status-word taxonomy: `Healthy / 31 MED + 16 LOW; F-36 (status-word taxonomy: `Healthy /
Disabled / Error`), F-49 (decimal-separator codemod for DE), Disabled / Error`), F-49 (decimal-separator codemod for DE),
F-37/F-38 (trailing-colon form-label sweep) are the F-37/F-38 (trailing-colon form-label sweep) are the
highest-leverage. Run them **before** the Insights redesign highest-leverage. Run them **before** the Insights redesign
so the new surface inherits the canonical vocabulary. so the new surface inherits the canonical vocabulary.
+22 -3
View File
@@ -15,15 +15,17 @@ _None._
## HIGH ## HIGH
### CR-HIGH-01 — Coach SSE error frames return 503 but the streaming hook never reads them ### CR-HIGH-01 — Coach SSE error frames return 503 but the streaming hook never reads them
**File**: `src/app/api/insights/chat/route.ts:412-426` and `src/components/insights/coach-panel/use-coach.ts:293-302` **File**: `src/app/api/insights/chat/route.ts:412-426` and `src/components/insights/coach-panel/use-coach.ts:293-302`
**What**: When the provider chain fails or the user has no provider, `streamProviderError()` returns a `Response` with `status: 503` whose body is an SSE stream containing `{type:"error",code:"coach.provider.unavailable"}`. On the client, `useSendCoachMessage` checks `if (!response.ok || !response.body)` and short-circuits with `errorCode: \`coach.http.${response.status}\``. The stream — including the error frame the route emitted — is never read. The user sees `coach.http.503` which `MessageThread.safeError` falls back to `t("insights.coach.errorProvider")`, so the *visible* copy is OK, but the rich `code` channel on the SSE event is wasted, and the route emits a body that no current client consumes. **What**: When the provider chain fails or the user has no provider, `streamProviderError()` returns a `Response` with `status: 503` whose body is an SSE stream containing `{type:"error",code:"coach.provider.unavailable"}`. On the client, `useSendCoachMessage` checks `if (!response.ok || !response.body)` and short-circuits with `errorCode: \`coach.http.${response.status}\``. The stream — including the error frame the route emitted — is never read. The user sees `coach.http.503`which`MessageThread.safeError`falls back to`t("insights.coach.errorProvider")`, so the *visible* copy is OK, but the rich `code` channel on the SSE event is wasted, and the route emits a body that no current client consumes.
**Why**: The route's SSE error path is dead code from the client's perspective. If a future iteration adds richer error handling that depends on parsing the `error` frame's `code`, it will silently never fire. This is also a footgun for the next maintainer reading the route — they'll assume the client honours the SSE error. **Why**: The route's SSE error path is dead code from the client's perspective. If a future iteration adds richer error handling that depends on parsing the `error` frame's `code`, it will silently never fire. This is also a footgun for the next maintainer reading the route — they'll assume the client honours the SSE error.
**Fix**: Either (a) flip the response to `status: 200` so the client reads the stream and extracts the structured error code, or (b) drop the SSE body in `streamProviderError` and return a JSON envelope with the error code so the client's HTTP-status branch sees a structured payload. **Fix**: Either (a) flip the response to `status: 200` so the client reads the stream and extracts the structured error code, or (b) drop the SSE body in `streamProviderError` and return a JSON envelope with the error code so the client's HTTP-status branch sees a structured payload.
### CR-HIGH-02 — `useSendCoachMessage` re-creates `send` every render and double-abort risk on parent re-render ### CR-HIGH-02 — `useSendCoachMessage` re-creates `send` every render and double-abort risk on parent re-render
**File**: `src/components/insights/coach-panel/use-coach.ts:240-382` **File**: `src/components/insights/coach-panel/use-coach.ts:240-382`
**What**: `useSendCoachMessage` accepts `opts: UseSendCoachMessageOptions = {}` and lists `[opts, queryClient]` in `send`'s `useCallback` deps. The drawer mounts the hook with an inline object literal `useSendCoachMessage({ onDone: (resolvedId) => setCurrentConversationId(resolvedId) })` (`coach-drawer.tsx:106-110`). Every render creates a new object identity, so `send` is re-memoised every render. While streaming, no caller invokes `send` so the in-flight reader keeps running, but if the user clicks "send" twice in quick succession across two renders, the second `send()` call invokes `abortRef.current?.abort()` and tears down the first stream — a behaviour the comment ("Cancel any prior in-flight request") does intend, but the user has not opted into it. **What**: `useSendCoachMessage` accepts `opts: UseSendCoachMessageOptions = {}` and lists `[opts, queryClient]` in `send`'s `useCallback` deps. The drawer mounts the hook with an inline object literal `useSendCoachMessage({ onDone: (resolvedId) => setCurrentConversationId(resolvedId) })` (`coach-drawer.tsx:106-110`). Every render creates a new object identity, so `send` is re-memoised every render. While streaming, no caller invokes `send` so the in-flight reader keeps running, but if the user clicks "send" twice in quick succession across two renders, the second `send()` call invokes `abortRef.current?.abort()` and tears down the first stream — a behaviour the comment ("Cancel any prior in-flight request") does intend, but the user has not opted into it.
@@ -33,15 +35,17 @@ _None._
**Fix**: Stash `opts` (or just `opts.onDone`) into a `useRef` updated in a render-effect-free way, and pass `[queryClient]` to the `useCallback` deps. Alternatively, drop `opts` from the deps and reference the latest via the ref. **Fix**: Stash `opts` (or just `opts.onDone`) into a `useRef` updated in a render-effect-free way, and pass `[queryClient]` to the `useCallback` deps. Alternatively, drop `opts` from the deps and reference the latest via the ref.
### CR-HIGH-03 — Idempotency wrapper double-reads the request body ### CR-HIGH-03 — Idempotency wrapper double-reads the request body
**File**: `src/app/api/insights/chat/route.ts:429-450` **File**: `src/app/api/insights/chat/route.ts:429-450`
**What**: The outer `apiHandler` clones the request, parses its JSON to detect `conversationId`, and **then** dispatches into `handleChatRequest(request)` (the original) which calls `request.json()` again at line 132. Both paths read the body. `request.clone()` ostensibly returns an independent stream, so the original `request` is still fresh — but the comment block in the route admits the cloning is hot-path logic ("validation will surface the issue inside the handler"). If a future runtime tightens body-stream semantics or `withIdempotency` itself begins consuming the body, the double-read becomes silently wrong (handler sees an empty body, surfaces 400). There is also no body-size cap on the clone read; a 4 KB body is fine but a runaway 10 MB JSON would be parsed twice. **What**: The outer `apiHandler` clones the request, parses its JSON to detect `conversationId`, and **then** dispatches into `handleChatRequest(request)` (the original) which calls `request.json()` again at line 132. Both paths read the body. `request.clone()` ostensibly returns an independent stream, so the original `request` is still fresh — but the comment block in the route admits the cloning is hot-path logic ("validation will surface the issue inside the handler"). If a future runtime tightens body-stream semantics or `withIdempotency` itself begins consuming the body, the double-read becomes silently wrong (handler sees an empty body, surfaces 400). There is also no body-size cap on the clone read; a 4 KB body is fine but a runaway 10 MB JSON would be parsed twice.
**Why**: Streaming-body parsers are notoriously version-fragile. The current Next.js + Node combo allows the clone, but the reliance is implicit. Worse, when `conversationId` is present the handler runs *without* idempotency protection — a duplicate POST with the same `Idempotency-Key` to an existing thread will replay the provider call and double-bill the budget. **Why**: Streaming-body parsers are notoriously version-fragile. The current Next.js + Node combo allows the clone, but the reliance is implicit. Worse, when `conversationId` is present the handler runs _without_ idempotency protection — a duplicate POST with the same `Idempotency-Key` to an existing thread will replay the provider call and double-bill the budget.
**Fix**: Read + parse the body **once** at the top of the handler, then pass the parsed object into the inner function; or hoist the conversationId parse into a tiny helper that operates on a cached JSON read. Document that follow-up turns deliberately skip idempotency, or apply it (the tests in `coach-conversations.test.ts` don't probe this case). **Fix**: Read + parse the body **once** at the top of the handler, then pass the parsed object into the inner function; or hoist the conversationId parse into a tiny helper that operates on a cached JSON read. Document that follow-up turns deliberately skip idempotency, or apply it (the tests in `coach-conversations.test.ts` don't probe this case).
### CR-HIGH-04 — `streamProviderError` parameter typed as `ReturnType<typeof Object.assign>` (≈ `any`) ### CR-HIGH-04 — `streamProviderError` parameter typed as `ReturnType<typeof Object.assign>` (≈ `any`)
**File**: `src/app/api/insights/chat/route.ts:406-410` **File**: `src/app/api/insights/chat/route.ts:406-410`
**What**: `streamProviderError({ conversationId, snapshot: ReturnType<typeof Object.assign>, code })` — the `snapshot` parameter is typed `ReturnType<typeof Object.assign>` which TypeScript widens to `any`. This is a `noImplicitAny` escape hatch that defeats strict mode. The function never actually uses `snapshot` (look at the body — it only encodes the error frame), so the type tells the wrong story too. **What**: `streamProviderError({ conversationId, snapshot: ReturnType<typeof Object.assign>, code })` — the `snapshot` parameter is typed `ReturnType<typeof Object.assign>` which TypeScript widens to `any`. This is a `noImplicitAny` escape hatch that defeats strict mode. The function never actually uses `snapshot` (look at the body — it only encodes the error frame), so the type tells the wrong story too.
@@ -53,6 +57,7 @@ _None._
## MED ## MED
### CR-MED-01 — `useEffect` for streaming auto-scroll lists `streaming?.content` but not `streaming` identity ### CR-MED-01 — `useEffect` for streaming auto-scroll lists `streaming?.content` but not `streaming` identity
**File**: `src/components/insights/coach-panel/message-thread.tsx:72-78` **File**: `src/components/insights/coach-panel/message-thread.tsx:72-78`
**What**: The auto-scroll effect depends on `[messages.length, streaming?.content]`. When the parent flips from one streaming session to a fresh one with content `""`, the effect won't fire (length unchanged, content unchanged from "" → ""), and the viewport may stay scrolled mid-thread. Mostly harmless because `messages.length` will tick when a new persisted message lands, but the dependency is brittle. **What**: The auto-scroll effect depends on `[messages.length, streaming?.content]`. When the parent flips from one streaming session to a fresh one with content `""`, the effect won't fire (length unchanged, content unchanged from "" → ""), and the viewport may stay scrolled mid-thread. Mostly harmless because `messages.length` will tick when a new persisted message lands, but the dependency is brittle.
@@ -60,6 +65,7 @@ _None._
**Fix**: Add `streaming?.inProgress` to the dep list (or track a turn counter). **Fix**: Add `streaming?.inProgress` to the dep list (or track a turn counter).
### CR-MED-02 — `bandFromInterval` can return a misleading "high" label on a tight CI around r ≈ 0 ### CR-MED-02 — `bandFromInterval` can return a misleading "high" label on a tight CI around r ≈ 0
**File**: `src/lib/insights/correlations.ts:558-570` **File**: `src/lib/insights/correlations.ts:558-570`
**What**: `bandFromInterval([-0.05, 0.10])` → width 0.15 → label "high". A "high confidence" chip on a near-zero correlation reads as "we're confident the effect is meaningful" but the CI just confirms the effect is small. The card is gated by `pValue < 0.05` upstream which prevents the worst case, but a borderline-significant near-zero r could surface a "High confidence" chip alongside an interpretation that says "do not move together strongly". **What**: `bandFromInterval([-0.05, 0.10])` → width 0.15 → label "high". A "high confidence" chip on a near-zero correlation reads as "we're confident the effect is meaningful" but the CI just confirms the effect is small. The card is gated by `pValue < 0.05` upstream which prevents the worst case, but a borderline-significant near-zero r could surface a "High confidence" chip alongside an interpretation that says "do not move together strongly".
@@ -67,6 +73,7 @@ _None._
**Fix**: Demote the chip to "moderate" when the CI bracket straddles zero, or rename the chip to something like "tight CI" / "wide CI" so the language doesn't conflate "narrow CI" with "meaningful effect". **Fix**: Demote the chip to "moderate" when the CI bracket straddles zero, or rename the chip to something like "tight CI" / "wide CI" so the language doesn't conflate "narrow CI" with "meaningful effect".
### CR-MED-03 — Pearson p-value uses normal-approximation across the entire surfacing range ### CR-MED-03 — Pearson p-value uses normal-approximation across the entire surfacing range
**File**: `src/lib/insights/correlations.ts:182-199` **File**: `src/lib/insights/correlations.ts:182-199`
**What**: `twoSidedPFromT(absT, df)` uses `2 * (1 - normalCdf(absT))` for `df ≥ 12` and a "crude correction" `* 0.85` below that. Comment claims accuracy "to within ~0.005 over the surfacing range" but at the n=14 (df=12) gate the t-distribution's heavier tail can put a true p≈0.04 result at p≈0.025 under the normal approximation — a 1.6× understatement of significance. The threshold-crossing risk is real. **What**: `twoSidedPFromT(absT, df)` uses `2 * (1 - normalCdf(absT))` for `df ≥ 12` and a "crude correction" `* 0.85` below that. Comment claims accuracy "to within ~0.005 over the surfacing range" but at the n=14 (df=12) gate the t-distribution's heavier tail can put a true p≈0.04 result at p≈0.025 under the normal approximation — a 1.6× understatement of significance. The threshold-crossing risk is real.
@@ -74,13 +81,15 @@ _None._
**Fix**: Either pull in a tiny incomplete-beta implementation (e.g. ~30 LOC) or raise the gate to df ≥ 20 to keep the normal approx honest. **Fix**: Either pull in a tiny incomplete-beta implementation (e.g. ~30 LOC) or raise the gate to df ≥ 20 to keep the normal approx honest.
### CR-MED-04 — `weightTrendAlignment` uses ±2 kg target band derived from a single number, not the user's actual band ### CR-MED-04 — `weightTrendAlignment` uses ±2 kg target band derived from a single number, not the user's actual band
**File**: `src/lib/analytics/health-score.ts:289-294` **File**: `src/lib/analytics/health-score.ts:289-294`
**What**: `deriveWeightTarget(targetKg)` always returns `{ min: target-2, max: target+2 }`. The user's stored `weightTargetKg` is a point estimate; the score then awards 100 only when *latest* falls in the ±2 kg band. A user with a stable target weight of 80 kg and a steady 79 kg actual reading scores 100; the same user at 77 kg scores < 100 even with a flat trend. There's no clinical basis for ±2 kg specifically — pulling from the existing `value-bands` infrastructure (which has a real green/orange band defined per-user via `buildWeightRangeFromHeight`) would be more honest. **What**: `deriveWeightTarget(targetKg)` always returns `{ min: target-2, max: target+2 }`. The user's stored `weightTargetKg` is a point estimate; the score then awards 100 only when _latest_ falls in the ±2 kg band. A user with a stable target weight of 80 kg and a steady 79 kg actual reading scores 100; the same user at 77 kg scores < 100 even with a flat trend. There's no clinical basis for ±2 kg specifically — pulling from the existing `value-bands` infrastructure (which has a real green/orange band defined per-user via `buildWeightRangeFromHeight`) would be more honest.
**Fix**: Reuse `buildWeightRangeFromHeight(user.heightCm)` (already imported in the analytics route) and pass the green band as the `target`. Optional: fall back to ±2 kg only when `heightCm` is unset. **Fix**: Reuse `buildWeightRangeFromHeight(user.heightCm)` (already imported in the analytics route) and pass the green band as the `target`. Optional: fall back to ±2 kg only when `heightCm` is unset.
### CR-MED-05 — `computeUserHealthScore` re-uses identical `bpInTargetPct` for both current and previous snapshot ### CR-MED-05 — `computeUserHealthScore` re-uses identical `bpInTargetPct` for both current and previous snapshot
**File**: `src/app/api/analytics/route.ts:509-520` **File**: `src/app/api/analytics/route.ts:509-520`
**What**: The "vs last week" delta is supposed to compare against a 7-day-shifted snapshot, but the BP component is held constant ("we'd need to rewind the all-time aggregate"). Three of the four pillars move; one is frozen. When the BP component dominates the weight (0.30 of 1.00), the delta under-represents real change. The comment acknowledges the trade-off, but the surface text reads "vs last week" without disclosing the frozen pillar. **What**: The "vs last week" delta is supposed to compare against a 7-day-shifted snapshot, but the BP component is held constant ("we'd need to rewind the all-time aggregate"). Three of the four pillars move; one is frozen. When the BP component dominates the weight (0.30 of 1.00), the delta under-represents real change. The comment acknowledges the trade-off, but the surface text reads "vs last week" without disclosing the frozen pillar.
@@ -88,6 +97,7 @@ _None._
**Fix**: Either compute a real 7-days-prior `bpInTargetPct` (re-pair sys+dia rows up to `prevUntil`) or relabel the delta as "vs last week (BP held constant)" / show the per-component delta breakdown so the user knows where the move came from. **Fix**: Either compute a real 7-days-prior `bpInTargetPct` (re-pair sys+dia rows up to `prevUntil`) or relabel the delta as "vs last week (BP held constant)" / show the per-component delta breakdown so the user knows where the move came from.
### CR-MED-06 — `WeeklyReportView` early-returns the loading spinner when advisor is loading, even after the report is cached ### CR-MED-06 — `WeeklyReportView` early-returns the loading spinner when advisor is loading, even after the report is cached
**File**: `src/components/insights/weekly-report-view.tsx:85-94` **File**: `src/components/insights/weekly-report-view.tsx:85-94`
**What**: `if (authLoading || advisor.isLoading) return <Loader2 />`. `useInsightsAdvisorQuery` returns `isLoading: true` only on first mount; subsequent navigations hit the cached payload and `isLoading` is false from the first render. Edge case: a hard reload of `/insights/report/[week]` with no cache will spin even when the cached payload would arrive within ~50ms. Fine for UX, but the spinner blocks the auto-print effect: `if (advisor.isLoading) return` at line 73 means `?print=1` deep-links from the hero banner won't fire `window.print()` until after the cache hydrates — and the 300 ms timer is reset on every rerender of that effect's deps, which can be flaky. **What**: `if (authLoading || advisor.isLoading) return <Loader2 />`. `useInsightsAdvisorQuery` returns `isLoading: true` only on first mount; subsequent navigations hit the cached payload and `isLoading` is false from the first render. Edge case: a hard reload of `/insights/report/[week]` with no cache will spin even when the cached payload would arrive within ~50ms. Fine for UX, but the spinner blocks the auto-print effect: `if (advisor.isLoading) return` at line 73 means `?print=1` deep-links from the hero banner won't fire `window.print()` until after the cache hydrates — and the 300 ms timer is reset on every rerender of that effect's deps, which can be flaky.
@@ -95,6 +105,7 @@ _None._
**Fix**: Once `matchedReport` is non-null, fire the print timer once and gate it on a ref so a second-render isLoading flip doesn't restart it. **Fix**: Once `matchedReport` is non-null, fire the print timer once and gate it on a ref so a second-render isLoading flip doesn't restart it.
### CR-MED-07 — `<TrendsRow>` accepts an optional `confidence` prop but the parent never passes it ### CR-MED-07 — `<TrendsRow>` accepts an optional `confidence` prop but the parent never passes it
**File**: `src/components/insights/trends-row.tsx:71-75` + `src/app/insights/page.tsx:983-985` **File**: `src/components/insights/trends-row.tsx:71-75` + `src/app/insights/page.tsx:983-985`
**What**: The Trends row exposes a `confidence?: { bp?: …, weight?: …, mood?: … }` prop that the underlying `<TrendAnnotation>` renders as a chip. The page only passes `annotations`. Either the wiring was deferred (the comment mentions "from a backing correlation gives us one") and is a TODO, or the prop is dead surface area. Tests for `<TrendAnnotation>` cover the chip but no test threads the data through `<TrendsRow>`. **What**: The Trends row exposes a `confidence?: { bp?: …, weight?: …, mood?: … }` prop that the underlying `<TrendAnnotation>` renders as a chip. The page only passes `annotations`. Either the wiring was deferred (the comment mentions "from a backing correlation gives us one") and is a TODO, or the prop is dead surface area. Tests for `<TrendAnnotation>` cover the chip but no test threads the data through `<TrendsRow>`.
@@ -102,6 +113,7 @@ _None._
**Fix**: Either wire the `analytics.correlations` confidence bands into the page's `<TrendsRow>` invocation (BP chart ↔ bp-compliance card's confidence band, etc.), or drop the prop until B3.x lands the wiring. **Fix**: Either wire the `analytics.correlations` confidence bands into the page's `<TrendsRow>` invocation (BP chart ↔ bp-compliance card's confidence band, etc.), or drop the prop until B3.x lands the wiring.
### CR-MED-08 — Coach `provenanceFromJson` accepts an unsafe `windows` filter cast ### CR-MED-08 — Coach `provenanceFromJson` accepts an unsafe `windows` filter cast
**File**: `src/lib/ai/coach/persistence.ts:78-87` **File**: `src/lib/ai/coach/persistence.ts:78-87`
**What**: The decoder filters `parsed.windows` for typeof string then casts to the strict union `ReadonlyArray<…>`. A poisoned row (e.g. legacy migration from a forked setup) could surface a "potato" window string that the UI's `WINDOW_KEYS` lookup then returns `undefined` from, rendering a chip with `t(undefined)` (which surfaces as the literal string `"undefined"`). Same pattern for metrics. **What**: The decoder filters `parsed.windows` for typeof string then casts to the strict union `ReadonlyArray<…>`. A poisoned row (e.g. legacy migration from a forked setup) could surface a "potato" window string that the UI's `WINDOW_KEYS` lookup then returns `undefined` from, rendering a chip with `t(undefined)` (which surfaces as the literal string `"undefined"`). Same pattern for metrics.
@@ -109,6 +121,7 @@ _None._
**Fix**: Validate against the `WINDOW_KEYS` allow-list (e.g. `["last7days","last30days","last90days","allTime"]`) before adding to the array. The set already exists in `source-chips.tsx` — extract to a shared constant. **Fix**: Validate against the `WINDOW_KEYS` allow-list (e.g. `["last7days","last30days","last90days","allTime"]`) before adding to the array. The set already exists in `source-chips.tsx` — extract to a shared constant.
### CR-MED-09 — `bpStoryboardAnnotations` resolves color via a string lookup that silently falls back to purple ### CR-MED-09 — `bpStoryboardAnnotations` resolves color via a string lookup that silently falls back to purple
**File**: `src/app/insights/page.tsx:915-935` **File**: `src/app/insights/page.tsx:915-935`
**What**: `STORYBOARD_COLOR_BY_CATEGORY[entry.category] ?? "var(--dracula-purple)"` — when the AI emits a category outside the four-element schema enum, the fallback paints purple, which is also one of the canonical colours. So the UI silently surfaces a wrong category as a valid one. The schema's `superRefine` should catch this at parse time, but the schema is `.passthrough()` and the page reads `advisor.payload.insights.storyboardAnnotations` raw, not via the validated `storyboardAnnotationsSchema`. **What**: `STORYBOARD_COLOR_BY_CATEGORY[entry.category] ?? "var(--dracula-purple)"` — when the AI emits a category outside the four-element schema enum, the fallback paints purple, which is also one of the canonical colours. So the UI silently surfaces a wrong category as a valid one. The schema's `superRefine` should catch this at parse time, but the schema is `.passthrough()` and the page reads `advisor.payload.insights.storyboardAnnotations` raw, not via the validated `storyboardAnnotationsSchema`.
@@ -118,6 +131,7 @@ _None._
## LOW ## LOW
### CR-LOW-01 — `<HeroStrip>` greeting bucket calls `now ?? new Date()` on every render, not cached ### CR-LOW-01 — `<HeroStrip>` greeting bucket calls `now ?? new Date()` on every render, not cached
**File**: `src/components/insights/hero-strip.tsx:163` **File**: `src/components/insights/hero-strip.tsx:163`
**What**: A new `Date` per render. The bucketed key is stable across the hour, but a chatty parent that re-renders the hero on every TanStack Query tick still allocates. Tiny — call it polish. **What**: A new `Date` per render. The bucketed key is stable across the hour, but a chatty parent that re-renders the hero on every TanStack Query tick still allocates. Tiny — call it polish.
@@ -125,6 +139,7 @@ _None._
**Fix**: `useMemo(() => resolveGreetingKey(now ?? new Date()), [now])` so SSR + client first paint match. **Fix**: `useMemo(() => resolveGreetingKey(now ?? new Date()), [now])` so SSR + client first paint match.
### CR-LOW-02 — `<HeroStrip>` `formatRelativeTime` reads `Date.now()` per render ### CR-LOW-02 — `<HeroStrip>` `formatRelativeTime` reads `Date.now()` per render
**File**: `src/components/insights/hero-strip.tsx:138-148` and `daily-briefing.tsx:259-273` **File**: `src/components/insights/hero-strip.tsx:138-148` and `daily-briefing.tsx:259-273`
**What**: Same helper duplicated across two files; same `Date.now()` per render → minor SSR mismatch risk if the timestamp lands within a few ms of a bucket boundary. Both files acknowledge the duplication via comments. **What**: Same helper duplicated across two files; same `Date.now()` per render → minor SSR mismatch risk if the timestamp lands within a few ms of a bucket boundary. Both files acknowledge the duplication via comments.
@@ -132,6 +147,7 @@ _None._
**Fix**: Extract to `@/lib/insights/relative-time.ts` and reuse. **Fix**: Extract to `@/lib/insights/relative-time.ts` and reuse.
### CR-LOW-03 — `Sheet` has nested `<Sheet>` children for the mobile rail trays ### CR-LOW-03 — `Sheet` has nested `<Sheet>` children for the mobile rail trays
**File**: `src/components/insights/coach-panel/coach-drawer.tsx:131-275` **File**: `src/components/insights/coach-panel/coach-drawer.tsx:131-275`
**What**: Two `<Sheet>` portals nested inside a third `<Sheet>` portal. Radix supports it, but the dom focus trap on the outer drawer + inner tray sheets can briefly fight when both open. Not observed yet; flag for B5/v1.4.21 polish. **What**: Two `<Sheet>` portals nested inside a third `<Sheet>` portal. Radix supports it, but the dom focus trap on the outer drawer + inner tray sheets can briefly fight when both open. Not observed yet; flag for B5/v1.4.21 polish.
@@ -139,6 +155,7 @@ _None._
**Fix**: Promote the trays to siblings of the outer sheet (mount alongside, not inside). Optional. **Fix**: Promote the trays to siblings of the outer sheet (mount alongside, not inside). Optional.
### CR-LOW-04 — `summariseTitle` ellipsis cut at TITLE_MAX-1 can land mid-multibyte sequence ### CR-LOW-04 — `summariseTitle` ellipsis cut at TITLE_MAX-1 can land mid-multibyte sequence
**File**: `src/lib/ai/coach/persistence.ts:34-45` **File**: `src/lib/ai/coach/persistence.ts:34-45`
**What**: `collapsed.slice(0, 79)` operates on UTF-16 code units, not grapheme clusters. A user message ending at byte 78 mid-emoji (e.g. "🩺" is two code units) gets a half-character on disk that may render as "?" in the rail. **What**: `collapsed.slice(0, 79)` operates on UTF-16 code units, not grapheme clusters. A user message ending at byte 78 mid-emoji (e.g. "🩺" is two code units) gets a half-character on disk that may render as "?" in the rail.
@@ -146,6 +163,7 @@ _None._
**Fix**: Spread to an array first (`[...collapsed]`) so the slice is grapheme-aware. The unit test covers the `[...out].length` length-cap but not multibyte content. **Fix**: Spread to an array first (`[...collapsed]`) so the slice is grapheme-aware. The unit test covers the `[...out].length` length-cap but not multibyte content.
### CR-LOW-05 — `bpInTargetRate` for the "previous" snapshot is held constant, but the test never asserts the delta is reasonable ### CR-LOW-05 — `bpInTargetRate` for the "previous" snapshot is held constant, but the test never asserts the delta is reasonable
**File**: `src/lib/analytics/__tests__/health-score.test.ts` + the route **File**: `src/lib/analytics/__tests__/health-score.test.ts` + the route
**What**: The compute path holds BP rate constant for the previous snapshot; no test exercises a scenario where weight + mood + compliance all shift but BP doesn't, asserting that the delta reflects only the moving pillars. Coverage gap. **What**: The compute path holds BP rate constant for the previous snapshot; no test exercises a scenario where weight + mood + compliance all shift but BP doesn't, asserting that the delta reflects only the moving pillars. Coverage gap.
@@ -153,6 +171,7 @@ _None._
**Fix**: Add a test that holds bpInTargetRate constant across both snapshots and verifies delta reflects only weight/mood/compliance moves. **Fix**: Add a test that holds bpInTargetRate constant across both snapshots and verifies delta reflects only weight/mood/compliance moves.
### CR-LOW-06 — `streamRefusal` persists the user message + refusal but bypasses `enforceBudget` accounting ### CR-LOW-06 — `streamRefusal` persists the user message + refusal but bypasses `enforceBudget` accounting
**File**: `src/app/api/insights/chat/route.ts:347-404` **File**: `src/app/api/insights/chat/route.ts:347-404`
**What**: Refusal path runs the `enforceBudget` check at the top of `handleChatRequest` (line 149) but never writes anything to the `CoachUsage` ledger. A determined attacker can send 1000 prompt-injection messages a day and burn DB writes (one user-row + one assistant-row each) without the budget meter ticking. **What**: Refusal path runs the `enforceBudget` check at the top of `handleChatRequest` (line 149) but never writes anything to the `CoachUsage` ledger. A determined attacker can send 1000 prompt-injection messages a day and burn DB writes (one user-row + one assistant-row each) without the budget meter ticking.
+21 -2
View File
@@ -11,26 +11,30 @@
## HIGH ## HIGH
### H1 — Coach drawer width busts the "1080 px" decision on common laptop screens ### H1 — Coach drawer width busts the "1080 px" decision on common laptop screens
- Surface: AI Coach drawer · `src/components/insights/coach-panel/coach-drawer.tsx:137` - Surface: AI Coach drawer · `src/components/insights/coach-panel/coach-drawer.tsx:137`
- What: The `<SheetContent>` is `w-full p-0 sm:max-w-[720px] lg:max-w-[1080px]`. On a 1280-px (most-popular laptop) viewport the drawer paints at 1080 px, leaving **only 200 px** of underlying `/insights` visible. On a 1366 px ThinkPad it's 286 px. The "keep dashboard context behind it" goal (file's own header comment) is unattainable below ~1440 px. - What: The `<SheetContent>` is `w-full p-0 sm:max-w-[720px] lg:max-w-[1080px]`. On a 1280-px (most-popular laptop) viewport the drawer paints at 1080 px, leaving **only 200 px** of underlying `/insights` visible. On a 1366 px ThinkPad it's 286 px. The "keep dashboard context behind it" goal (file's own header comment) is unattainable below ~1440 px.
- Why: Either drop the cap to 920 px, or make it `lg:max-w-[min(1080px,calc(100vw-320px))]` so the dashboard always retains a readable column. Apple's HIG recommends the underlying content stay >= 320 px wide for sheet patterns. Today the drawer reads as a full-screen takeover on every laptop except 4K externals. - Why: Either drop the cap to 920 px, or make it `lg:max-w-[min(1080px,calc(100vw-320px))]` so the dashboard always retains a readable column. Apple's HIG recommends the underlying content stay >= 320 px wide for sheet patterns. Today the drawer reads as a full-screen takeover on every laptop except 4K externals.
- Fix: switch the cap to `lg:max-w-[min(1080px,calc(100vw-360px))] xl:max-w-[1080px]` (or accept that the drawer is full-takeover and remove the multi-column body until xl). - Fix: switch the cap to `lg:max-w-[min(1080px,calc(100vw-360px))] xl:max-w-[1080px]` (or accept that the drawer is full-takeover and remove the multi-column body until xl).
### H2 — Touch targets on the suggested-prompt chips are 28 px tall — far under 36 px baseline ### H2 — Touch targets on the suggested-prompt chips are 28 px tall — far under 36 px baseline
- Surface: Suggested prompts · `src/components/insights/suggested-prompts.tsx:74` - Surface: Suggested prompts · `src/components/insights/suggested-prompts.tsx:74`
- What: chips are `px-3 py-1.5 text-xs`. The y-extent is ~28 px. Settings audit baseline (v1.4.16 phase B6) put the floor at 36 px on mobile, and Apple HIG / Material both call for 44 px. On a 375 px viewport the chip strip becomes a fingertip lottery. - What: chips are `px-3 py-1.5 text-xs`. The y-extent is ~28 px. Settings audit baseline (v1.4.16 phase B6) put the floor at 36 px on mobile, and Apple HIG / Material both call for 44 px. On a 375 px viewport the chip strip becomes a fingertip lottery.
- Why: This is the user's primary entry point into the new Coach. Mis-taps = drop-off. - Why: This is the user's primary entry point into the new Coach. Mis-taps = drop-off.
- Fix: bump to `px-3.5 py-2 text-[13px]` (≈ 36 px). On `sm:` keep the smaller variant if vertical real-estate matters, but mobile must hit 36+. - Fix: bump to `px-3.5 py-2 text-[13px]` (≈ 36 px). On `sm:` keep the smaller variant if vertical real-estate matters, but mobile must hit 36+.
### H3 — Streaming assistant text has no `aria-live` region ### H3 — Streaming assistant text has no `aria-live` region
- Surface: Coach drawer message thread · `src/components/insights/coach-panel/message-thread.tsx:185-218` - Surface: Coach drawer message thread · `src/components/insights/coach-panel/message-thread.tsx:185-218`
- What: Assistant bubble updates in place as the SSE stream lands characters. There's no `aria-live="polite"` (or `role="log" aria-busy=...`) on the bubble or its container. Screen-reader users hear silence while sighted users see live text. - What: Assistant bubble updates in place as the SSE stream lands characters. There's no `aria-live="polite"` (or `role="log" aria-busy=...`) on the bubble or its container. Screen-reader users hear silence while sighted users see live text.
- Why: WCAG 2.1 SC 4.1.3 + the project's own pattern (`<DailyBriefing>` already uses `aria-live="polite"` for the loading label). - Why: WCAG 2.1 SC 4.1.3 + the project's own pattern (`<DailyBriefing>` already uses `aria-live="polite"` for the loading label).
- Fix: wrap the streaming bubble in `<div role="log" aria-live="polite" aria-relevant="additions text">`. Reset the text on each new turn so SR users don't hear the previous reply re-read. Alternative: announce only the final reply (`aria-live="polite"` set after streaming completes). - Fix: wrap the streaming bubble in `<div role="log" aria-live="polite" aria-relevant="additions text">`. Reset the text on each new turn so SR users don't hear the previous reply re-read. Alternative: announce only the final reply (`aria-live="polite"` set after streaming completes).
### H4 — Sticky section-nav z-index conflict with the hero gradient on iOS ### H4 — Sticky section-nav z-index conflict with the hero gradient on iOS
- Surface: `/insights` page · `src/app/insights/page.tsx:1715` - Surface: `/insights` page · `src/app/insights/page.tsx:1715`
- What: `<InsightsSectionNav>` mounts at line 1000 with `sticky top-0 z-30`. It sits *below* the hero strip in DOM order, so on scroll it correctly slides up. But it carries `bg-background/80 backdrop-blur-sm` — and the hero's `glow-purple` shadow (`box-shadow … 0 18px 48px -28px`) bleeds **above** the nav while it's still above the fold. Once you scroll, the nav becomes a 40-px purple-tinted strip with a faint readability problem (purple shadow of the just-departed hero gradient still reflecting through the blur). - What: `<InsightsSectionNav>` mounts at line 1000 with `sticky top-0 z-30`. It sits _below_ the hero strip in DOM order, so on scroll it correctly slides up. But it carries `bg-background/80 backdrop-blur-sm` — and the hero's `glow-purple` shadow (`box-shadow … 0 18px 48px -28px`) bleeds **above** the nav while it's still above the fold. Once you scroll, the nav becomes a 40-px purple-tinted strip with a faint readability problem (purple shadow of the just-departed hero gradient still reflecting through the blur).
- Why: visual confusion on the most important navigation surface of the page. - Why: visual confusion on the most important navigation surface of the page.
- Fix: increase `bg-background/80` to `bg-background/95` (or `bg-card/95`) on the sticky nav, and clip the hero glow with `overflow-hidden` on the immediate parent (the hero already has `overflow-hidden` but the `box-shadow` paints outside the clip). Easiest: drop `glow-purple` for an inset shadow. - Fix: increase `bg-background/80` to `bg-background/95` (or `bg-card/95`) on the sticky nav, and clip the hero glow with `overflow-hidden` on the immediate parent (the hero already has `overflow-hidden` but the `box-shadow` paints outside the clip). Easiest: drop `glow-purple` for an inset shadow.
@@ -39,47 +43,56 @@
## MED ## MED
### M1 — Hero strip greeting clock is wall-clock, not Berlin ### M1 — Hero strip greeting clock is wall-clock, not Berlin
- Surface: hero strip · `src/components/insights/hero-strip.tsx:122-128, 163` - Surface: hero strip · `src/components/insights/hero-strip.tsx:122-128, 163`
- What: `now ?? new Date()` reads the device's local time, then `.getHours()`. A user in Madrid logging in at 04:00 local sees "Good evening" — fine; but a user travelling who wants the Berlin time used for everything else in the app sees a different bucket from the rest of the UI. The CLAUDE.md says "Europe/Berlin for display". - What: `now ?? new Date()` reads the device's local time, then `.getHours()`. A user in Madrid logging in at 04:00 local sees "Good evening" — fine; but a user travelling who wants the Berlin time used for everything else in the app sees a different bucket from the rest of the UI. The CLAUDE.md says "Europe/Berlin for display".
- Fix: pass the current Berlin hour through (e.g. `Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Berlin", hour: "numeric" })` or use the existing `useFormatters` helper). Tests can keep the `now` override. - Fix: pass the current Berlin hour through (e.g. `Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Berlin", hour: "numeric" })` or use the existing `useFormatters` helper). Tests can keep the `now` override.
### M2 — German hero greeting buckets collapse 23:00–04:59 into "Guten Abend" ### M2 — German hero greeting buckets collapse 23:00–04:59 into "Guten Abend"
- Surface: hero strip · `src/components/insights/hero-strip.tsx:127` - Surface: hero strip · `src/components/insights/hero-strip.tsx:127`
- What: comment in `resolveGreetingKey` says "Night → Guten Abend so 03:00 doesn't read as Morgen". Fine; but the TWO buckets (`heroGreetingEvening` and `heroGreetingNight`) point to the same German string in `messages/de.json` (`Guten Abend`). That works but the duplicate key is dead weight. - What: comment in `resolveGreetingKey` says "Night → Guten Abend so 03:00 doesn't read as Morgen". Fine; but the TWO buckets (`heroGreetingEvening` and `heroGreetingNight`) point to the same German string in `messages/de.json` (`Guten Abend`). That works but the duplicate key is dead weight.
- Fix: drop `heroGreetingNight`, fall through to `heroGreetingEvening` in the resolver. - Fix: drop `heroGreetingNight`, fall through to `heroGreetingEvening` in the resolver.
### M3 — Health Score panel title-stack on mobile leaves 220 px score card below the prompt chips ### M3 — Health Score panel title-stack on mobile leaves 220 px score card below the prompt chips
- Surface: hero strip + health score · `src/components/insights/hero-strip.tsx:188-329` - Surface: hero strip + health score · `src/components/insights/hero-strip.tsx:188-329`
- What: on `<lg` the order is greeting → subtitle → meta row → weekly banner → action row → prompt chips → **health score panel**. By the time the user scrolls to the score, they've already passed everything else. The score IS the headline metric — burying it last on mobile inverts the visual hierarchy. - What: on `<lg` the order is greeting → subtitle → meta row → weekly banner → action row → prompt chips → **health score panel**. By the time the user scrolls to the score, they've already passed everything else. The score IS the headline metric — burying it last on mobile inverts the visual hierarchy.
- Fix: on `<lg` mount the Health Score panel between the meta row and the weekly banner (`order-2` Tailwind utility on the panel + `order-1/order-3` on siblings). Or render a compact "score chip" inline with the meta row on mobile and the full card only on `lg+`. - Fix: on `<lg` mount the Health Score panel between the meta row and the weekly banner (`order-2` Tailwind utility on the panel + `order-1/order-3` on siblings). Or render a compact "score chip" inline with the meta row on mobile and the full card only on `lg+`.
### M4 — Weekly-report eyebrow chip is uppercase with a `tracking-wide` — collides with the in-app rule of "sentence case" ### M4 — Weekly-report eyebrow chip is uppercase with a `tracking-wide` — collides with the in-app rule of "sentence case"
- Surface: weekly report · `src/components/insights/weekly-report-view.tsx:163-169` - Surface: weekly report · `src/components/insights/weekly-report-view.tsx:163-169`
- What: `bg-dracula-purple/15 text-dracula-purple … uppercase tracking-wide` on the "Weekly report" tag. The CLAUDE.md user feedback (`feedback_marc_voice_english`) plus the v1.4.16 settings audit baseline both point at sentence-case for everything user-facing. The Coach rail labels use `uppercase` too (`coach-history-rail.tsx:96`, `sources-rail.tsx:79`, daily-briefing key-findings header) — pattern-wide. - What: `bg-dracula-purple/15 text-dracula-purple … uppercase tracking-wide` on the "Weekly report" tag. The CLAUDE.md user feedback (`feedback_marc_voice_english`) plus the v1.4.16 settings audit baseline both point at sentence-case for everything user-facing. The Coach rail labels use `uppercase` too (`coach-history-rail.tsx:96`, `sources-rail.tsx:79`, daily-briefing key-findings header) — pattern-wide.
- Why: micro-typography drift; reads like a marketing eyebrow rather than the calm-document tone the rest of HealthLog has. - Why: micro-typography drift; reads like a marketing eyebrow rather than the calm-document tone the rest of HealthLog has.
- Fix: drop `uppercase` + `tracking-wide`, keep the `text-[11px]` to retain the rank. - Fix: drop `uppercase` + `tracking-wide`, keep the `text-[11px]` to retain the rank.
### M5 — Coach drawer header has no visible focus trap when both rail trays are open ### M5 — Coach drawer header has no visible focus trap when both rail trays are open
- Surface: Coach drawer · `src/components/insights/coach-panel/coach-drawer.tsx:235-274` - Surface: Coach drawer · `src/components/insights/coach-panel/coach-drawer.tsx:235-274`
- What: the parent drawer is a Sheet (focus trapped by Radix). The two mobile rail trays at lines 235 + 259 are also Sheets, mounted **as siblings** of the parent SheetContent inside the same parent Sheet's content. Radix's portal pushes both onto the same z-50 layer; ESC handling is per-Sheet but Radix's focus-trap applies to the *outermost* trap. In practice on iOS Safari the focus can escape into the underlying `/insights` body once a tray is closed because the close-restore-focus target is set to `document.body`. - What: the parent drawer is a Sheet (focus trapped by Radix). The two mobile rail trays at lines 235 + 259 are also Sheets, mounted **as siblings** of the parent SheetContent inside the same parent Sheet's content. Radix's portal pushes both onto the same z-50 layer; ESC handling is per-Sheet but Radix's focus-trap applies to the _outermost_ trap. In practice on iOS Safari the focus can escape into the underlying `/insights` body once a tray is closed because the close-restore-focus target is set to `document.body`.
- Fix: render the rail trays as nested Sheets (i.e. children of the parent SheetContent — currently they are, but Radix's Portal lifts them above the parent). Either set `<Portal container=…>` to the parent SheetContent's ref, or use a `<Drawer>`-style stacked-modals primitive (`vaul` already in deps). At minimum, capture the rail-tray-trigger button refs and explicitly `triggerRef.current?.focus()` in the tray's `onCloseAutoFocus`. - Fix: render the rail trays as nested Sheets (i.e. children of the parent SheetContent — currently they are, but Radix's Portal lifts them above the parent). Either set `<Portal container=…>` to the parent SheetContent's ref, or use a `<Drawer>`-style stacked-modals primitive (`vaul` already in deps). At minimum, capture the rail-tray-trigger button refs and explicitly `triggerRef.current?.focus()` in the tray's `onCloseAutoFocus`.
### M6 — Daily Briefing tone-bar visually clips at the rounded card corner ### M6 — Daily Briefing tone-bar visually clips at the rounded card corner
- Surface: daily briefing · `src/components/insights/daily-briefing.tsx:93-99` - Surface: daily briefing · `src/components/insights/daily-briefing.tsx:93-99`
- What: tone-bar is `absolute top-3 bottom-3 left-0 w-[3px] rounded-r` over a card with `rounded-md`. The bar's vertical extent (`top-3 bottom-3`) inset cleanly, but the bar uses `rounded-r` (right side rounded), and it sits at `left-0` exactly on the card's outer rounded corner — the bar's left edge coincides with the card's rounded boundary, so the bar's straight left side pokes into the corner curve. Same shape on `correlation-card.tsx:89` (identical pattern). - What: tone-bar is `absolute top-3 bottom-3 left-0 w-[3px] rounded-r` over a card with `rounded-md`. The bar's vertical extent (`top-3 bottom-3`) inset cleanly, but the bar uses `rounded-r` (right side rounded), and it sits at `left-0` exactly on the card's outer rounded corner — the bar's left edge coincides with the card's rounded boundary, so the bar's straight left side pokes into the corner curve. Same shape on `correlation-card.tsx:89` (identical pattern).
- Fix: either inset by `left-1` / `top-2 bottom-2` to keep the bar inside the rounded corner, or add `overflow-hidden` to the parent card so the bar visually clips. - Fix: either inset by `left-1` / `top-2 bottom-2` to keep the bar inside the rounded corner, or add `overflow-hidden` to the parent card so the bar visually clips.
### M7 — "Try a 7-day experiment" CTA is a primary `variant="default"` Button — but disabled ### M7 — "Try a 7-day experiment" CTA is a primary `variant="default"` Button — but disabled
- Surface: correlation card · `src/components/insights/correlation-card.tsx:138-150` - Surface: correlation card · `src/components/insights/correlation-card.tsx:138-150`
- What: the CTA paints in the primary purple (high contrast), but is `disabled` with a `title="Coming soon"`. Disabled-but-primary reads as "the system is broken right now" rather than "feature coming soon". Compare to the hero's "Generate weekly report" which uses the same disabled-default-button pattern at `hero-strip.tsx:246-257`. - What: the CTA paints in the primary purple (high contrast), but is `disabled` with a `title="Coming soon"`. Disabled-but-primary reads as "the system is broken right now" rather than "feature coming soon". Compare to the hero's "Generate weekly report" which uses the same disabled-default-button pattern at `hero-strip.tsx:246-257`.
- Fix: switch to `variant="outline"` while disabled; flip back to default when the feature ships. Or move it out of the card body into a footer link styled as `text-muted-foreground hover:text-foreground`. (Same recommendation for the hero "Generate weekly report" before B6 wires it.) - Fix: switch to `variant="outline"` while disabled; flip back to default when the feature ships. Or move it out of the card body into a footer link styled as `text-muted-foreground hover:text-foreground`. (Same recommendation for the hero "Generate weekly report" before B6 wires it.)
### M8 — Source chip pluralisation is "n=…" — locale-agnostic but reads as engineering output ### M8 — Source chip pluralisation is "n=…" — locale-agnostic but reads as engineering output
- Surface: coach source chips · `src/components/insights/coach-panel/source-chips.tsx:89` - Surface: coach source chips · `src/components/insights/coach-panel/source-chips.tsx:89`
- What: chip renders `· n=12`. The German locale should read `· n=12` too (it's a math symbol), but the prefix character "n" plus the equals sign feels like raw stats output, not user-facing copy. The Trends row already does better (`based on {n} paired readings · {window}`). - What: chip renders `· n=12`. The German locale should read `· n=12` too (it's a math symbol), but the prefix character "n" plus the equals sign feels like raw stats output, not user-facing copy. The Trends row already does better (`based on {n} paired readings · {window}`).
- Fix: use `{count} samples` / `{count} Werte` or drop the count entirely on mobile and surface it in a tooltip on the chip. - Fix: use `{count} samples` / `{count} Werte` or drop the count entirely on mobile and surface it in a tooltip on the chip.
### M9 — `disabled` settings cog button on Coach header has a tooltip but no visible affordance ### M9 — `disabled` settings cog button on Coach header has a tooltip but no visible affordance
- Surface: Coach drawer · `src/components/insights/coach-panel/coach-drawer.tsx:173-192` - Surface: Coach drawer · `src/components/insights/coach-panel/coach-drawer.tsx:173-192`
- What: the gear icon stays full-opacity even while `disabled` (because `Button` only sets `disabled:opacity-50` from `buttonVariants`). Combined with `aria-label={t("insights.coach.settings")}` and the deferred-feature tooltip, screen-reader users get told "Coach settings" but the feature does nothing. Sighted users get a fully-styled icon with a "Coach settings arrive in v1.4.21" tooltip on hover. - What: the gear icon stays full-opacity even while `disabled` (because `Button` only sets `disabled:opacity-50` from `buttonVariants`). Combined with `aria-label={t("insights.coach.settings")}` and the deferred-feature tooltip, screen-reader users get told "Coach settings" but the feature does nothing. Sighted users get a fully-styled icon with a "Coach settings arrive in v1.4.21" tooltip on hover.
- Fix: either drop the button entirely until v1.4.21 (the v1.4.16 settings audit playbook), or set `aria-label="Coach settings (coming in v1.4.21)"` so the SR experience matches the visual. - Fix: either drop the button entirely until v1.4.21 (the v1.4.16 settings audit playbook), or set `aria-label="Coach settings (coming in v1.4.21)"` so the SR experience matches the visual.
@@ -89,21 +102,27 @@
## LOW ## LOW
### L1 — Hero "personal-baseline" copy reads as engineering: "Based on your last 90 days" ### L1 — Hero "personal-baseline" copy reads as engineering: "Based on your last 90 days"
- `src/components/insights/hero-strip.tsx:215-217` — the meta-row label `t("insights.heroPersonalBaseline")` is the same line for every user, so it isn't really a baseline. Either drop it or surface the actual sample size ("From 187 readings over 90 days"). - `src/components/insights/hero-strip.tsx:215-217` — the meta-row label `t("insights.heroPersonalBaseline")` is the same line for every user, so it isn't really a baseline. Either drop it or surface the actual sample size ("From 187 readings over 90 days").
### L2 — `disabled` HealthScoreCard component sub-bars all paint at the score's band colour even when their sub-value is in a different band ### L2 — `disabled` HealthScoreCard component sub-bars all paint at the score's band colour even when their sub-value is in a different band
- `src/components/insights/health-score-card.tsx:200-204` — every sub-bar uses `BAND_PROGRESS_CLASS[band]` (the **composite** band) for its fill. A user with a green composite score but a yellow weight component sees a green weight bar. Sub-bars should derive their own band per component for visual fidelity. - `src/components/insights/health-score-card.tsx:200-204` — every sub-bar uses `BAND_PROGRESS_CLASS[band]` (the **composite** band) for its fill. A user with a green composite score but a yellow weight component sees a green weight bar. Sub-bars should derive their own band per component for visual fidelity.
### L3 — Weekly report print stylesheet drops the eyebrow chip but keeps `bg-dracula-purple/15` ### L3 — Weekly report print stylesheet drops the eyebrow chip but keeps `bg-dracula-purple/15`
- `src/components/insights/weekly-report-view.tsx:165` — Tailwind `print:` utilities reset the wrapper's `bg-gradient-to-b` (line 161) but not the eyebrow chip's `bg-dracula-purple/15`. On a black-and-white printer the chip will print as a grey block. Add `print:bg-transparent print:px-0 print:text-foreground`. - `src/components/insights/weekly-report-view.tsx:165` — Tailwind `print:` utilities reset the wrapper's `bg-gradient-to-b` (line 161) but not the eyebrow chip's `bg-dracula-purple/15`. On a black-and-white printer the chip will print as a grey block. Add `print:bg-transparent print:px-0 print:text-foreground`.
### L4 — Source-chip "fresh" indicator uses a green-only static dot with no SR announcement difference ### L4 — Source-chip "fresh" indicator uses a green-only static dot with no SR announcement difference
- `src/components/insights/coach-panel/sources-rail.tsx:103-106` — `aria-label={t("insights.coach.sourcesFresh")}` is hard-coded as "Fresh". The comment at line 16 acknowledges v1.4.21 will plug in real fresh/stale state. Until then, set `aria-hidden="true"` on the dot so SR users don't get told "Fresh" five times in a row. - `src/components/insights/coach-panel/sources-rail.tsx:103-106` — `aria-label={t("insights.coach.sourcesFresh")}` is hard-coded as "Fresh". The comment at line 16 acknowledges v1.4.21 will plug in real fresh/stale state. Until then, set `aria-hidden="true"` on the dot so SR users don't get told "Fresh" five times in a row.
### L5 — Storyboard annotation labels truncate to 24 chars on `<sm` but English copy can have a 25-char "Started Lisinopril 10mg dose" ### L5 — Storyboard annotation labels truncate to 24 chars on `<sm` but English copy can have a 25-char "Started Lisinopril 10mg dose"
- `src/components/charts/health-chart.tsx:1184-1187` — 24-char limit was chosen against the artboard's German labels which run wider; the truncation char itself isn't shown (`annotation.truncatedLabel` field). On a 375 px chart the label paints at the end with no ellipsis. Add `…` suffix when truncated. - `src/components/charts/health-chart.tsx:1184-1187` — 24-char limit was chosen against the artboard's German labels which run wider; the truncation char itself isn't shown (`annotation.truncatedLabel` field). On a 375 px chart the label paints at the end with no ellipsis. Add `…` suffix when truncated.
### L6 — Coach composer placeholder leaks `…` (ellipsis) but the keyboard-hint uses `Shift+Enter` / `Umschalt+Enter` — locale-correct but inconsistent with the rest of the app ### L6 — Coach composer placeholder leaks `…` (ellipsis) but the keyboard-hint uses `Shift+Enter` / `Umschalt+Enter` — locale-correct but inconsistent with the rest of the app
- `messages/{en,de}.json` `insights.coach.composerHint` — the rest of the app uses `Shift+Enter` even in German (`Umschalt` rarely surfaces on macOS keyboards). Marc's voice rule prefers the `Shift` literal. Reconsider the German translation. - `messages/{en,de}.json` `insights.coach.composerHint` — the rest of the app uses `Shift+Enter` even in German (`Umschalt` rarely surfaces on macOS keyboards). Marc's voice rule prefers the `Shift` literal. Reconsider the German translation.
--- ---
+37 -36
View File
@@ -77,7 +77,7 @@ None of these block the tag.
section status cards already render the same numbers. The four- section status cards already render the same numbers. The four-
tile artboard layout will only land when B3's trends row tile artboard layout will only land when B3's trends row
reorganises the per-section blocks. Tracked in `.planning/v1421- reorganises the per-section blocks. Tracked in `.planning/v1421-
backlog.md`. Not urgent; the briefing fills the role. backlog.md`. Not urgent; the briefing fills the role.
--- ---
@@ -99,7 +99,7 @@ In priority order:
malformed payload, round-trip. Drawer mounts from the hero malformed payload, round-trip. Drawer mounts from the hero
strip's "Ask the coach" button and from suggested-prompt chips; strip's "Ask the coach" button and from suggested-prompt chips;
`key={prefill}` on `<CoachDrawer>` sidesteps `react-hooks/set- `key={prefill}` on `<CoachDrawer>` sidesteps `react-hooks/set-
state-in-effect` so the lazy `useState` initialiser fires fresh state-in-effect` so the lazy `useState` initialiser fires fresh
on every chip transition. **This is the feature that defines on every chip transition. **This is the feature that defines
v1.4.20** — every other piece of the redesign supports it. Files: v1.4.20** — every other piece of the redesign supports it. Files:
`src/app/api/insights/chat/route.ts`, `src/lib/ai/coach/*`, `src/app/api/insights/chat/route.ts`, `src/lib/ai/coach/*`,
@@ -113,16 +113,16 @@ In priority order:
(weekly-report, ask-coach, regenerate), a 5-chip suggested-prompt (weekly-report, ask-coach, regenerate), a 5-chip suggested-prompt
strip, and a full-width Daily Briefing card with 0–5 finding rows strip, and a full-width Daily Briefing card with 0–5 finding rows
(each tone-coloured: good/watch/info, with optional delta badge (each tone-coloured: good/watch/info, with optional delta badge
+ metric icon). Schema bumped: `dailyBriefingSchema` is - metric icon). Schema bumped: `dailyBriefingSchema` is
`nullable().optional()` on the response root so 4.19 caches `nullable().optional()` on the response root so 4.19 caches
round-trip cleanly. PROMPT_VERSION 4.19.0 → 4.20.0 with round-trip cleanly. PROMPT_VERSION 4.19.0 → 4.20.0 with
GROUND RULE 8 added to EN+DE prompts. The Vitals tile row from GROUND RULE 8 added to EN+DE prompts. The Vitals tile row from
the artboard was deliberately deferred — the per-section status the artboard was deliberately deferred — the per-section status
cards already render the same numbers and a tile row above them cards already render the same numbers and a tile row above them
would duplicate the surface. Files: would duplicate the surface. Files:
`src/components/insights/{hero-strip,daily-briefing,suggested- `src/components/insights/{hero-strip,daily-briefing,suggested-
prompts}.tsx`, `src/lib/ai/schema.ts`. Report: prompts}.tsx`, `src/lib/ai/schema.ts`. Report:
`phase-B1-v1420-report.md`. `phase-B1-v1420-report.md`.
3. **Correlation discovery + Trends row (B3).** Three pre-defined 3. **Correlation discovery + Trends row (B3).** Three pre-defined
hypotheses surface as 2-up Correlation cards: BP × medication hypotheses surface as 2-up Correlation cards: BP × medication
@@ -140,26 +140,26 @@ In priority order:
**Auto-discovery is explicitly v1.5/v1.6 work**; this scope **Auto-discovery is explicitly v1.5/v1.6 work**; this scope
discipline is what kept the false-positive risk in check. Files: discipline is what kept the false-positive risk in check. Files:
`src/lib/insights/correlations.ts`, `src/components/insights/ `src/lib/insights/correlations.ts`, `src/components/insights/
{correlation-card,correlation-row,trend-annotation,trends-row}. {correlation-card,correlation-row,trend-annotation,trends-row}.
tsx`. Report: `phase-B3-report.md`. tsx`. Report: `phase-B3-report.md`.
4. **Weekly Report + Storyboard + Mobile passes (B4).** New route 4. **Weekly Report + Storyboard + Mobile passes (B4).** New route
`/insights/report/[week]` (printable via `window.print()`, `/insights/report/[week]` (printable via `window.print()`,
Tailwind `print:` variants for A4/Letter, hero banner card "Your Tailwind `print:` variants for A4/Letter, hero banner card "Your
Week N report is ready" links into it). New `weeklyReportSchema` Week N report is ready" links into it). New `weeklyReportSchema`
+ `storyboardAnnotationSchema` blocks added to the AI response - `storyboardAnnotationSchema` blocks added to the AI response
root, both `nullable().optional()` for legacy-cache round-trip. root, both `nullable().optional()` for legacy-cache round-trip.
`<HealthChart>` gained an additive `annotations[]` prop — `<HealthChart>` gained an additive `annotations[]` prop —
storyboard pins ride between cartesian grid and data lines so storyboard pins ride between cartesian grid and data lines so
they read as orientation, not data. `resolveAnnotationPositions` they read as orientation, not data. `resolveAnnotationPositions`
maps annotation dates onto bucketed point indices with a 7-day maps annotation dates onto bucketed point indices with a 7-day
snap window. Mobile rail trays for the Coach drawer (history + snap window. Mobile rail trays for the Coach drawer (history +
sources rails were desktop-only since B2b; B4 surfaces them on sources rails were desktop-only since B2b; B4 surfaces them on
`<lg` via two chevron-button triggers anchored to the inside `<lg` via two chevron-button triggers anchored to the inside
edges of the message thread). PROMPT_VERSION 4.20.1 → 4.20.2. edges of the message thread). PROMPT_VERSION 4.20.1 → 4.20.2.
Files: `src/app/insights/report/[week]/page.tsx`, `src/lib/ Files: `src/app/insights/report/[week]/page.tsx`, `src/lib/
insights/week-iso.ts`, `src/components/insights/weekly-report- insights/week-iso.ts`, `src/components/insights/weekly-report-
view.tsx`. Report: `phase-B4-report.md`. view.tsx`. Report: `phase-B4-report.md`.
5. **Personal Health Score panel (B5).** Server-deterministic 5. **Personal Health Score panel (B5).** Server-deterministic
composite of four weighted components: BP-in-target-rate (30%), composite of four weighted components: BP-in-target-rate (30%),
@@ -175,7 +175,7 @@ In priority order:
`weightTrendAlignment`, `moodStability` — so any downstream `weightTrendAlignment`, `moodStability` — so any downstream
consumer can recompute without re-running the route. Files: consumer can recompute without re-running the route. Files:
`src/lib/analytics/health-score.ts`, `src/components/insights/ `src/lib/analytics/health-score.ts`, `src/components/insights/
health-score-card.tsx`. Report: `phase-B5-report.md`. health-score-card.tsx`. Report: `phase-B5-report.md`.
--- ---
@@ -191,13 +191,14 @@ diff already applied). Apple Health adds new measurement sources
Coach should ingest the new metrics — extend the prompt + schema. Coach should ingest the new metrics — extend the prompt + schema.
The iOS handoff already lays out 9 phases (P0 done, P1 networking The iOS handoff already lays out 9 phases (P0 done, P1 networking
+ auth, P2 design system, P3 onboarding + HealthKit, P4 dashboard,
P5 capture + meds + mood, P6 charts + insights, P7 settings + - auth, P2 design system, P3 onboarding + HealthKit, P4 dashboard,
doctor report, P8 notifications + widgets, P9 polish). My v1.5 P5 capture + meds + mood, P6 charts + insights, P7 settings +
plan is the **server-side counterpart**: what the web app + API doctor report, P8 notifications + widgets, P9 polish). My v1.5
need to do so the iOS phases land cleanly. Five phases, each ≤7 plan is the **server-side counterpart**: what the web app + API
days, sequential where they have to be and parallelisable where need to do so the iOS phases land cleanly. Five phases, each ≤7
they don't. days, sequential where they have to be and parallelisable where
they don't.
### Phase P1 — Apple Health ingest contract + measurement-source taxonomy (~5-7 days) ### Phase P1 — Apple Health ingest contract + measurement-source taxonomy (~5-7 days)
@@ -413,7 +414,7 @@ Six items that aren't on the iOS roadmap but should ride alongside:
risk is that v1.5 slips and the placeholder lives on the risk is that v1.5 slips and the placeholder lives on the
production hero for longer than advertised. Mitigation: ship production hero for longer than advertised. Mitigation: ship
v1.5 by the date-card said in `~/Projects/healthlog-iOS/ v1.5 by the date-card said in `~/Projects/healthlog-iOS/
HealthLogIOS/.planning/ROADMAP.md`. If it slips by more than 2 HealthLogIOS/.planning/ROADMAP.md`. If it slips by more than 2
weeks, revisit the placeholder copy. weeks, revisit the placeholder copy.
4. **OpenAPI spec drift between server and iOS DTOs.** The iOS app 4. **OpenAPI spec drift between server and iOS DTOs.** The iOS app
+28 -27
View File
@@ -16,34 +16,34 @@ unchanged).
## Commits ## Commits
| # | SHA | Subject | Reviewer cross-reference | | # | SHA | Subject | Reviewer cross-reference |
| - | --- | ------- | ------------------------ | | --- | --------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| 1 | `e632e26` | `fix(coach): repair SSE idempotency and error-frame handling` | Code-HIGH-1 + Code-HIGH-2 + Code-HIGH-3 + Code-HIGH-4 + Sec-M-1 + Sr-HIGH-1 + Simplify-S-01 | | 1 | `e632e26` | `fix(coach): repair SSE idempotency and error-frame handling` | Code-HIGH-1 + Code-HIGH-2 + Code-HIGH-3 + Code-HIGH-4 + Sec-M-1 + Sr-HIGH-1 + Simplify-S-01 |
| 2 | `249607d` | `perf(coach): bound buildCoachSnapshot to last 90 days` | Sr-HIGH-3 | | 2 | `249607d` | `perf(coach): bound buildCoachSnapshot to last 90 days` | Sr-HIGH-3 |
| 3 | `046d788` | `feat(insights): enable Generate weekly report button on hero` | Design pushback (item 6) | | 3 | `046d788` | `feat(insights): enable Generate weekly report button on hero` | Design pushback (item 6) |
| 4 | `3c353fe` | `fix(insights): bump suggested-prompt chip touch target to 36px floor` | Design-HIGH-2 | | 4 | `3c353fe` | `fix(insights): bump suggested-prompt chip touch target to 36px floor` | Design-HIGH-2 |
| 5 | `8479e05` | `a11y(coach): announce streaming assistant text to screen readers` | Design-HIGH-3 | | 5 | `8479e05` | `a11y(coach): announce streaming assistant text to screen readers` | Design-HIGH-3 |
| 6 | `f9af100` | `fix(coach): cap drawer width on lg+ and route sources rail via tray` | Design-HIGH-1 | | 6 | `f9af100` | `fix(coach): cap drawer width on lg+ and route sources rail via tray` | Design-HIGH-1 |
| 7 | `346867b` | `fix(insights): isolate hero glow from sticky section nav` | Design-HIGH-4 | | 7 | `346867b` | `fix(insights): isolate hero glow from sticky section nav` | Design-HIGH-4 |
| 8 | `cfe6d1c` | `chore(api): drop maintainer-name comment in analytics route` | Sec-LOW-4 | | 8 | `cfe6d1c` | `chore(api): drop maintainer-name comment in analytics route` | Sec-LOW-4 |
| 9 | `3402394` | `refactor(insights): consolidate relative-time and confidence-band tables` | Simplify-S-02 + Simplify-S-03 + Simplify-S-04 + Simplify-S-05 + Code-LOW-02 | | 9 | `3402394` | `refactor(insights): consolidate relative-time and confidence-band tables` | Simplify-S-02 + Simplify-S-03 + Simplify-S-04 + Simplify-S-05 + Code-LOW-02 |
| 10 | `9f3baf9` | `fix(insights): MED-bucket polish across coach + report surfaces` | Design-M4 + Design-M7 + Design-M9 + Design-L3 + Design-L4 + Code-LOW-04 | | 10 | `9f3baf9` | `fix(insights): MED-bucket polish across coach + report surfaces` | Design-M4 + Design-M7 + Design-M9 + Design-L3 + Design-L4 + Code-LOW-04 |
| 11 | `31fbf98` | `chore(i18n): drop duplicate heroGreetingNight key` | Design-M2 | | 11 | `31fbf98` | `chore(i18n): drop duplicate heroGreetingNight key` | Design-M2 |
11 commits in total (one extra from the small Design-M2 cleanup that 11 commits in total (one extra from the small Design-M2 cleanup that
folded cleanly into a focused commit alongside the simplify pass). folded cleanly into a focused commit alongside the simplify pass).
## Findings buckets ## Findings buckets
| Bucket | Applied inline | Deferred to v1.4.21 backlog | | Bucket | Applied inline | Deferred to v1.4.21 backlog |
| ------ | -------------- | --------------------------- | | -------------------- | -------------------------------------- | --------------------------- |
| CRITICAL | 0 (none flagged) | 0 | | CRITICAL | 0 (none flagged) | 0 |
| HIGH | 13 | 0 | | HIGH | 13 | 0 |
| MED | 6 | 22 | | MED | 6 | 22 |
| LOW | 4 | 16 | | LOW | 4 | 16 |
| Simplify apply-yes | 5 | 0 | | Simplify apply-yes | 5 | 0 |
| Simplify apply-maybe | 0 | 4 | | Simplify apply-maybe | 0 | 4 |
| Simplify apply-no | 0 (rejected with reason; not deferred) | 0 | | Simplify apply-no | 0 (rejected with reason; not deferred) | 0 |
### HIGH applied (13) ### HIGH applied (13)
@@ -82,6 +82,7 @@ Simplify-S-01 was the load-bearing one; the rest are tag-along
behaviour-preserving cleanups.) behaviour-preserving cleanups.)
### Items deferred to `.planning/v1421-backlog.md` "Phase D ### Items deferred to `.planning/v1421-backlog.md` "Phase D
reconcile carry-over" reconcile carry-over"
22 MED + 16 LOW + 4 simplify-apply-maybe — full list with one-liner 22 MED + 16 LOW + 4 simplify-apply-maybe — full list with one-liner
@@ -130,11 +131,11 @@ fix in this reconcile:
## Verification gates ## Verification gates
| Gate | Pre-reconcile | Post-reconcile | | Gate | Pre-reconcile | Post-reconcile |
| ---- | ------------- | -------------- | | ----------------------- | --------------------------------------------------------------- | --------------------------------------------------------------------- |
| `pnpm typecheck` | clean | clean | | `pnpm typecheck` | clean | clean |
| `pnpm lint` | 0 errors / 13 warn | 0 errors / 13 warn | | `pnpm lint` | 0 errors / 13 warn | 0 errors / 13 warn |
| `pnpm test --run` | 237 files / 2026 tests | 237 files / 2026 tests | | `pnpm test --run` | 237 files / 2026 tests | 237 files / 2026 tests |
| `pnpm test:integration` | 21 files / 81 tests (occasional flake on coach-chat round-trip) | 21 files / 81 tests (same flake observed; not regressed by reconcile) | | `pnpm test:integration` | 21 files / 81 tests (occasional flake on coach-chat round-trip) | 21 files / 81 tests (same flake observed; not regressed by reconcile) |
The integration-suite flake on `coach-chat.test.ts` "round-trips an The integration-suite flake on `coach-chat.test.ts` "round-trips an
+25 -11
View File
@@ -22,14 +22,17 @@ emitted by `GET /api/analytics`.
storage/observability DoS vectors, not auth/crypto failures. storage/observability DoS vectors, not auth/crypto failures.
## CRITICAL ## CRITICAL
*(none)*
_(none)_
## HIGH ## HIGH
*(none)*
_(none)_
## MED ## MED
### M-1 — Idempotent replay of an SSE-stream response returns garbled JSON ### M-1 — Idempotent replay of an SSE-stream response returns garbled JSON
- **File**: `src/app/api/insights/chat/route.ts:429-450` together with - **File**: `src/app/api/insights/chat/route.ts:429-450` together with
`src/lib/idempotency.ts:78-83, 220-237` `src/lib/idempotency.ts:78-83, 220-237`
- **What**: When the user creates a new conversation, `withIdempotency()` - **What**: When the user creates a new conversation, `withIdempotency()`
@@ -49,13 +52,14 @@ emitted by `GET /api/analytics`.
first turn of a chat. No data loss server-side (the message is first turn of a chat. No data loss server-side (the message is
persisted before streaming), so this is correctness-not-security. persisted before streaming), so this is correctness-not-security.
- **Fix**: Either (a) skip idempotency persistence for `Content-Type: - **Fix**: Either (a) skip idempotency persistence for `Content-Type:
text/event-stream` responses (cheapest — add a content-type check text/event-stream` responses (cheapest — add a content-type check
alongside the existing `SECRET_PATTERN` check), or (b) replay with the alongside the existing `SECRET_PATTERN` check), or (b) replay with the
original `Content-Type` header preserved and the raw text body. (a) original `Content-Type` header preserved and the raw text body. (a)
is simpler and matches the spirit of "don't cache responses whose is simpler and matches the spirit of "don't cache responses whose
shape isn't a JSON envelope". shape isn't a JSON envelope".
### M-2 — Refusal path persists messages without budget accounting ### M-2 — Refusal path persists messages without budget accounting
- **File**: `src/app/api/insights/chat/route.ts:158-170, 347-404` - **File**: `src/app/api/insights/chat/route.ts:158-170, 347-404`
- **What**: When `detectRefusal()` returns `refuse: true` the route - **What**: When `detectRefusal()` returns `refuse: true` the route
calls `streamRefusal()`, which (a) creates a new conversation if calls `streamRefusal()`, which (a) creates a new conversation if
@@ -81,9 +85,10 @@ emitted by `GET /api/analytics`.
messages/day worst case, which is ~24 MB/user/day). messages/day worst case, which is ~24 MB/user/day).
### M-3 — Refusal patterns don't cover common injection synonyms ### M-3 — Refusal patterns don't cover common injection synonyms
- **File**: `src/lib/ai/coach/refusal.ts:69-85` - **File**: `src/lib/ai/coach/refusal.ts:69-85`
- **What**: The `INJECTION_PATTERNS` bank looks for `ignore / - **What**: The `INJECTION_PATTERNS` bank looks for `ignore /
disregard / override / forget / pretend / dan / jailbreak`-shaped disregard / override / forget / pretend / dan / jailbreak`-shaped
phrases. It does not catch `"forget what I told you above"`, phrases. It does not catch `"forget what I told you above"`,
`"do not follow your earlier directives"`, `"do not follow your earlier directives"`,
`"start fresh, you are a"`, `"new instructions:"`, or non-Latin `"start fresh, you are a"`, `"new instructions:"`, or non-Latin
@@ -93,7 +98,7 @@ emitted by `GET /api/analytics`.
- **Why MED**: Defence-in-depth — the **system prompt itself** - **Why MED**: Defence-in-depth — the **system prompt itself**
enforces the harder constraint ("only the snapshot, never invent enforces the harder constraint ("only the snapshot, never invent
numbers"), so a missed pattern lets the message reach the model but numbers"), so a missed pattern lets the message reach the model but
still doesn't usually leak. Risk shifts to *prompt-leaking*: still doesn't usually leak. Risk shifts to _prompt-leaking_:
`"What were your starting instructions about the snapshot?"` matches `"What were your starting instructions about the snapshot?"` matches
none of the regexes (no "reveal/print/show"-style verb tied to none of the regexes (no "reveal/print/show"-style verb tied to
"prompt"), reaches the model, and a sloppy model could echo the "prompt"), reaches the model, and a sloppy model could echo the
@@ -101,17 +106,19 @@ emitted by `GET /api/analytics`.
catches the obvious phrasings but not "what / which / how / can you catches the obvious phrasings but not "what / which / how / can you
share". share".
- **Fix**: Extend the bank with verbs `(forget|abandon|drop|skip|toss| - **Fix**: Extend the bank with verbs `(forget|abandon|drop|skip|toss|
bypass|circumvent)` paired with `(instructions?|rules?|prompt| bypass|circumvent)` paired with `(instructions?|rules?|prompt|
guidelines?|directives?|system\s+message)`, plus a "what guidelines?|directives?|system\s+message)`, plus a "what
(were|are)\s+your.*(instructions|prompt|rules)" probe pattern. A (were|are)\s+your.\*(instructions|prompt|rules)" probe pattern. A
zero-width-stripping normalisation pass (`message.replace(/[​- zero-width-stripping normalisation pass (`message.replace(/[​-
‍]/g, "")` before regex) closes the homoglyph split. ‍]/g, "")` before regex) closes the homoglyph split.
Genuine health questions don't trip these so false-positive cost Genuine health questions don't trip these so false-positive cost
stays low. stays low.
### M-4 — `assistantMessage` persisted before streaming, but token-budget ### M-4 — `assistantMessage` persisted before streaming, but token-budget
ledger updated AFTER. Crash-window double-charge becomes ledger updated AFTER. Crash-window double-charge becomes
crash-window double-credit crash-window double-credit
- **File**: `src/app/api/insights/chat/route.ts:282-298` - **File**: `src/app/api/insights/chat/route.ts:282-298`
- **What**: After a successful provider call the route does - **What**: After a successful provider call the route does
`appendMessage(...assistant...)` then `recordSpend(...)`. If the `appendMessage(...assistant...)` then `recordSpend(...)`. If the
@@ -136,7 +143,9 @@ crash-window double-credit
## LOW ## LOW
### L-1 — `coachChatRequestSchema.conversationId` accepts any string up ### L-1 — `coachChatRequestSchema.conversationId` accepts any string up
to 64 chars to 64 chars
- **File**: `src/lib/ai/coach/types.ts:33-38` - **File**: `src/lib/ai/coach/types.ts:33-38`
- **What**: `conversationId: z.string().min(1).max(64).optional()` — no - **What**: `conversationId: z.string().min(1).max(64).optional()` — no
cuid format check. Prisma cuids are 25 chars, so a 64-char arbitrary cuid format check. Prisma cuids are 25 chars, so a 64-char arbitrary
@@ -147,7 +156,9 @@ to 64 chars
`[A-Za-z0-9_-]{8,64}` if non-cuid ids are anticipated. Cosmetic. `[A-Za-z0-9_-]{8,64}` if non-cuid ids are anticipated. Cosmetic.
### L-2 — `streamProviderError()` snapshot parameter is typed ### L-2 — `streamProviderError()` snapshot parameter is typed
`ReturnType<typeof Object.assign>` — effectively `any` `ReturnType<typeof Object.assign>` — effectively `any`
- **File**: `src/app/api/insights/chat/route.ts:406-410` - **File**: `src/app/api/insights/chat/route.ts:406-410`
- **What**: The function signature uses - **What**: The function signature uses
`ReturnType<typeof Object.assign>` which TypeScript widens to a `ReturnType<typeof Object.assign>` which TypeScript widens to a
@@ -158,7 +169,9 @@ to 64 chars
interface, or type it as `CoachProvenance | null`. interface, or type it as `CoachProvenance | null`.
### L-3 — Refusal-injection conversation pollutes the rail when the ### L-3 — Refusal-injection conversation pollutes the rail when the
attacker provides an existing `conversationId` attacker provides an existing `conversationId`
- **File**: `src/app/api/insights/chat/route.ts:347-404` - **File**: `src/app/api/insights/chat/route.ts:347-404`
- **What**: If a user sends an injection attempt with a valid - **What**: If a user sends an injection attempt with a valid
`conversationId`, `streamRefusal()` appends the user attempt + a `conversationId`, `streamRefusal()` appends the user attempt + a
@@ -173,10 +186,12 @@ attacker provides an existing `conversationId`
refusal-only conversations. refusal-only conversations.
### L-4 — `feedback_no_pii_in_user_facing.md`: maintainer-name comment ### L-4 — `feedback_no_pii_in_user_facing.md`: maintainer-name comment
in pre-v1.4.20 file in pre-v1.4.20 file
- **File**: `src/app/api/analytics/route.ts:79` - **File**: `src/app/api/analytics/route.ts:79`
- **What**: `// of the '30T' sub-value. For Marc's data (572 paired - **What**: `// of the '30T' sub-value. For Marc's data (572 paired
readings,…)`. This was committed under v1.4.19 (`a856272`, readings,…)`. This was committed under v1.4.19 (`a856272`,
2026-05-10) — it predates v1.4.20 but is in the same file the 2026-05-10) — it predates v1.4.20 but is in the same file the
Health Score work touched, so flagging here for cleanup. Not Health Score work touched, so flagging here for cleanup. Not
user-facing (source comment, not a string a user can see), but the user-facing (source comment, not a string a user can see), but the
@@ -245,4 +260,3 @@ in pre-v1.4.20 file
cap could in principle race to ~2× the cap before the ledger cap could in principle race to ~2× the cap before the ledger
catches up. Acceptable in practice (the cap is per-day, not catches up. Acceptable in practice (the cap is per-day, not
per-second) but worth annotating. per-second) but worth annotating.
+60 -19
View File
@@ -24,6 +24,7 @@ maintainer's diff isn't asked the same question twice.
**Why**: The function takes a `snapshot: ReturnType<typeof Object.assign>` parameter (which evaluates to `any` — a type smell on its own) and never reads it inside the body. Three callers dutifully pass `snapshot.provenance` for nothing. Pure dead code on both sides of the call. **Why**: The function takes a `snapshot: ReturnType<typeof Object.assign>` parameter (which evaluates to `any` — a type smell on its own) and never reads it inside the body. Three callers dutifully pass `snapshot.provenance` for nothing. Pure dead code on both sides of the call.
**Before** (≤8 lines): **Before** (≤8 lines):
```ts ```ts
function streamProviderError(args: { function streamProviderError(args: {
conversationId: string; conversationId: string;
@@ -32,8 +33,13 @@ function streamProviderError(args: {
}): Response { }): Response {
const stream = new ReadableStream<Uint8Array>({ const stream = new ReadableStream<Uint8Array>({
start(controller) { start(controller) {
try { controller.enqueue(encodeFrame({ type: "error", code: args.code, message: args.code })); } try {
finally { controller.close(); } controller.enqueue(
encodeFrame({ type: "error", code: args.code, message: args.code }),
);
} finally {
controller.close();
}
}, },
}); });
return new Response(stream, { status: 503, headers: SSE_HEADERS }); return new Response(stream, { status: 503, headers: SSE_HEADERS });
@@ -41,12 +47,18 @@ function streamProviderError(args: {
``` ```
**After** (≤8 lines): **After** (≤8 lines):
```ts ```ts
function streamProviderError(args: { code: string }): Response { function streamProviderError(args: { code: string }): Response {
const stream = new ReadableStream<Uint8Array>({ const stream = new ReadableStream<Uint8Array>({
start(controller) { start(controller) {
try { controller.enqueue(encodeFrame({ type: "error", code: args.code, message: args.code })); } try {
finally { controller.close(); } controller.enqueue(
encodeFrame({ type: "error", code: args.code, message: args.code }),
);
} finally {
controller.close();
}
}, },
}); });
return new Response(stream, { status: 503, headers: SSE_HEADERS }); return new Response(stream, { status: 503, headers: SSE_HEADERS });
@@ -63,6 +75,7 @@ function streamProviderError(args: { code: string }): Response {
**Why**: Three live byte-identical copies of the same 14-line helper. Each comment even calls out "Mirrors the helper in `<InsightsPageHero>`" — the maintainers know it's duplicated. Drift risk: a later i18n key change has to land in three files. **Why**: Three live byte-identical copies of the same 14-line helper. Each comment even calls out "Mirrors the helper in `<InsightsPageHero>`" — the maintainers know it's duplicated. Drift risk: a later i18n key change has to land in three files.
**Before** (≤8 lines, repeated 3×): **Before** (≤8 lines, repeated 3×):
```ts ```ts
function formatRelativeTime(iso: string, t: …): string { function formatRelativeTime(iso: string, t: …): string {
const target = new Date(iso).getTime(); const target = new Date(iso).getTime();
@@ -78,12 +91,15 @@ function formatRelativeTime(iso: string, t: …): string {
``` ```
**After** (≤8 lines): **After** (≤8 lines):
```ts ```ts
// new file: src/lib/i18n/relative-time.ts // new file: src/lib/i18n/relative-time.ts
export function formatRelativeTime( export function formatRelativeTime(
iso: string, iso: string,
t: (key: string, params?: Record<string, string | number>) => string, t: (key: string, params?: Record<string, string | number>) => string,
): string { /* …same body, written once… */ } ): string {
/* …same body, written once… */
}
// callers: import { formatRelativeTime } from "@/lib/i18n/relative-time"; // callers: import { formatRelativeTime } from "@/lib/i18n/relative-time";
``` ```
@@ -96,6 +112,7 @@ export function formatRelativeTime(
**Why**: The hook ternary picks a different queryKey when `id` is null — but `enabled: id !== null` keeps the query disabled in that exact case, so the "null" key never gets a queryFn fire. Just key on `id` directly; React Query happily accepts `null` in the array. **Why**: The hook ternary picks a different queryKey when `id` is null — but `enabled: id !== null` keeps the query disabled in that exact case, so the "null" key never gets a queryFn fire. Just key on `id` directly; React Query happily accepts `null` in the array.
**Before**: **Before**:
```ts ```ts
return useQuery({ return useQuery({
queryKey: id ? QUERY_KEYS.one(id) : ["coachConversation", "null"], queryKey: id ? QUERY_KEYS.one(id) : ["coachConversation", "null"],
@@ -106,6 +123,7 @@ return useQuery({
``` ```
**After**: **After**:
```ts ```ts
return useQuery({ return useQuery({
queryKey: ["coachConversation", id] as const, queryKey: ["coachConversation", id] as const,
@@ -123,24 +141,32 @@ return useQuery({
**Why**: The map is declared inside the component body so it's recreated on every render of `<InsightsPage>` (a render-heavy page). Map is data-only — no closures over locals, no i18n keys. Module-scope `const` is the boring right answer. **Why**: The map is declared inside the component body so it's recreated on every render of `<InsightsPage>` (a render-heavy page). Map is data-only — no closures over locals, no i18n keys. Module-scope `const` is the boring right answer.
**Before**: **Before**:
```tsx ```tsx
export default function InsightsPage() { export default function InsightsPage() {
// …200+ lines of derived state… // …200+ lines of derived state…
const STORYBOARD_COLOR_BY_CATEGORY: Record<string, string> = { const STORYBOARD_COLOR_BY_CATEGORY: Record<string, string> = {
medication: "var(--dracula-pink)", event: "var(--dracula-cyan)", medication: "var(--dracula-pink)",
milestone: "var(--dracula-green)", warning: "var(--dracula-orange)", event: "var(--dracula-cyan)",
milestone: "var(--dracula-green)",
warning: "var(--dracula-orange)",
}; };
} }
``` ```
**After**: **After**:
```tsx ```tsx
const STORYBOARD_COLOR_BY_CATEGORY: Record<string, string> = { const STORYBOARD_COLOR_BY_CATEGORY: Record<string, string> = {
medication: "var(--dracula-pink)", event: "var(--dracula-cyan)", medication: "var(--dracula-pink)",
milestone: "var(--dracula-green)", warning: "var(--dracula-orange)", event: "var(--dracula-cyan)",
milestone: "var(--dracula-green)",
warning: "var(--dracula-orange)",
}; };
export default function InsightsPage() { /* … */ } export default function InsightsPage() {
/* … */
}
``` ```
--- ---
@@ -151,15 +177,19 @@ export default function InsightsPage() { /* … */ }
**Why**: Both cards declare the same `Record<"low"|"moderate"|"high", string>` for badge classes AND for translation-key labels — byte-identical class strings, only the i18n key prefix differs (`correlationRow.*` vs `trendAnnotation.*`). Move the classnames to one constant, keep each component's i18n key map local (it IS distinct copy). The class table is the load-bearing duplication. **Why**: Both cards declare the same `Record<"low"|"moderate"|"high", string>` for badge classes AND for translation-key labels — byte-identical class strings, only the i18n key prefix differs (`correlationRow.*` vs `trendAnnotation.*`). Move the classnames to one constant, keep each component's i18n key map local (it IS distinct copy). The class table is the load-bearing duplication.
**Before** (one of two copies, each ~14 lines): **Before** (one of two copies, each ~14 lines):
```tsx ```tsx
const CONFIDENCE_BADGE_CLASSNAME: Record<"low" | "moderate" | "high", string> = { const CONFIDENCE_BADGE_CLASSNAME: Record<"low" | "moderate" | "high", string> =
high: "border-dracula-green/40 bg-dracula-green/10 text-dracula-green", {
moderate: "border-dracula-orange/40 bg-dracula-orange/10 text-dracula-orange", high: "border-dracula-green/40 bg-dracula-green/10 text-dracula-green",
low: "border-dracula-comment/40 bg-dracula-comment/10 text-muted-foreground", moderate:
}; "border-dracula-orange/40 bg-dracula-orange/10 text-dracula-orange",
low: "border-dracula-comment/40 bg-dracula-comment/10 text-muted-foreground",
};
``` ```
**After**: **After**:
```tsx ```tsx
// new file: src/components/insights/confidence-badge.ts // new file: src/components/insights/confidence-badge.ts
export type ConfidenceBand = "low" | "moderate" | "high"; export type ConfidenceBand = "low" | "moderate" | "high";
@@ -180,6 +210,7 @@ export const CONFIDENCE_BADGE_CLASS: Record<ConfidenceBand, string> = {
**Why**: The only production caller (`/insights/page.tsx:1611`) never passes these slots — the JSDoc says they were a commit-2 boundary "until commit 3 lands". Commit 3 has long since landed. `<CoachDrawerBody>` has its own slot props which the SSR test harness uses; nobody injects against `<CoachDrawer>`. Removing them halves the prop surface. **Why**: The only production caller (`/insights/page.tsx:1611`) never passes these slots — the JSDoc says they were a commit-2 boundary "until commit 3 lands". Commit 3 has long since landed. `<CoachDrawerBody>` has its own slot props which the SSR test harness uses; nobody injects against `<CoachDrawer>`. Removing them halves the prop surface.
**Before** (≤8 lines): **Before** (≤8 lines):
```tsx ```tsx
historyRail?: React.ReactNode; historyRail?: React.ReactNode;
sourcesRail?: React.ReactNode; sourcesRail?: React.ReactNode;
@@ -191,6 +222,7 @@ composer={composer ?? <CoachInput … />}
``` ```
**After** (≤8 lines): **After** (≤8 lines):
```tsx ```tsx
// drop the three props on CoachDrawer entirely // drop the three props on CoachDrawer entirely
historyRail={<HistoryRail … />} historyRail={<HistoryRail … />}
@@ -217,23 +249,31 @@ composer={<CoachInput … />}
**Why**: `let cloneForCheck: NextRequest | undefined = undefined;` is assigned via `request.clone() as NextRequest`, then immediately consumed by `cloneForCheck.json()` — never referenced again, never read after the `try` block. Inline the `.clone().json()` call directly. **Why**: `let cloneForCheck: NextRequest | undefined = undefined;` is assigned via `request.clone() as NextRequest`, then immediately consumed by `cloneForCheck.json()` — never referenced again, never read after the `try` block. Inline the `.clone().json()` call directly.
**Before**: **Before**:
```ts ```ts
let cloneForCheck: NextRequest | undefined = undefined; let cloneForCheck: NextRequest | undefined = undefined;
let conversationId: string | undefined = undefined; let conversationId: string | undefined = undefined;
try { try {
cloneForCheck = request.clone() as NextRequest; cloneForCheck = request.clone() as NextRequest;
const body = await cloneForCheck.json(); const body = await cloneForCheck.json();
if (typeof body?.conversationId === "string") conversationId = body.conversationId; if (typeof body?.conversationId === "string")
} catch { /* fall through */ } conversationId = body.conversationId;
} catch {
/* fall through */
}
``` ```
**After**: **After**:
```ts ```ts
let conversationId: string | undefined = undefined; let conversationId: string | undefined = undefined;
try { try {
const body = await request.clone().json(); const body = await request.clone().json();
if (typeof body?.conversationId === "string") conversationId = body.conversationId; if (typeof body?.conversationId === "string")
} catch { /* fall through */ } conversationId = body.conversationId;
} catch {
/* fall through */
}
``` ```
**Why "maybe"**: the variable's only sin is verbosity; the logic is fine. Pure cosmetic gain. **Why "maybe"**: the variable's only sin is verbosity; the logic is fine. Pure cosmetic gain.
@@ -278,6 +318,7 @@ The German prompt mirrors the English one section-for-section. A single template
## Verification gates that won't break ## Verification gates that won't break
All proposals above are behaviour-preserving: All proposals above are behaviour-preserving:
- S-01: pure dead-parameter removal (pre-conditions unchanged at call sites). - S-01: pure dead-parameter removal (pre-conditions unchanged at call sites).
- S-02: extracted helper byte-identical to the three current copies. - S-02: extracted helper byte-identical to the three current copies.
- S-03: queryKey shape `["coachConversation", null]` is structurally equivalent for cache lookup. - S-03: queryKey shape `["coachConversation", null]` is structurally equivalent for cache lookup.
+8 -8
View File
@@ -4,14 +4,14 @@ Timestamp: 2026-05-10T14:34+02:00 (push)
## Verification ## Verification
| Step | Result | Notes | | Step | Result | Notes |
| ----------------------- | ------ | ---------------------------------------------------------------- | | ----------------------- | ------ | -------------------------------------------------------------- |
| `pnpm typecheck` | PASS | 0 errors | | `pnpm typecheck` | PASS | 0 errors |
| `pnpm lint` | PASS | 0 errors / 12 pre-existing baseline warnings (no new) | | `pnpm lint` | PASS | 0 errors / 12 pre-existing baseline warnings (no new) |
| `pnpm format:check` | PASS\* | only `.planning/*` + `docs/audit/*` baseline noise dirty (same | | `pnpm format:check` | PASS\* | only `.planning/*` + `docs/audit/*` baseline noise dirty (same |
| | | call as v1.4.18 / v1.4.16; no source-tree drift) | | | | call as v1.4.18 / v1.4.16; no source-tree drift) |
| `pnpm test` | PASS | 1672 / 1672 unit tests, 210 files, 4.59s | | `pnpm test` | PASS | 1672 / 1672 unit tests, 210 files, 4.59s |
| `pnpm test:integration` | PASS | 67 / 67 integration tests, 18 files, 8.96s | | `pnpm test:integration` | PASS | 67 / 67 integration tests, 18 files, 8.96s |
`pnpm build` and `pnpm e2e` deferred to CI Docker (Node 22) per `pnpm build` and `pnpm e2e` deferred to CI Docker (Node 22) per
CLAUDE.md. CLAUDE.md.
+3 -3
View File
@@ -9,9 +9,9 @@ Both runs `success`. Wave-C v1.4.16 fix (drop arm64 from main publish,
qemu-SIGILL root cause) continues to hold — neither the tag nor the main qemu-SIGILL root cause) continues to hold — neither the tag nor the main
build hung this release. build hung this release.
| Run | Branch | Conclusion | Notes | | Run | Branch | Conclusion | Notes |
| ----------- | --------- | ---------- | ---------------------------------------------- | | ----------- | --------- | ---------- | ------------------------------------------------------------- |
| 25624945158 | `v1.4.18` | success | tag build, pushes `:1.4.18` | | 25624945158 | `v1.4.18` | success | tag build, pushes `:1.4.18` |
| 25624944843 | `main` | success | main build, pushes `:latest` (amd64-only post-Wave-C v1.4.16) | | 25624944843 | `main` | success | main build, pushes `:latest` (amd64-only post-Wave-C v1.4.16) |
Both runs target the same head SHA `0243e208` (chore(release) commit on Both runs target the same head SHA `0243e208` (chore(release) commit on
+7 -7
View File
@@ -6,9 +6,9 @@ Status: complete · 2026-05-10T15:18+02:00
The two-branch model is documented in two places: The two-branch model is documented in two places:
| Surface | File | Audience | | Surface | File | Audience |
|---|---|---| | --------- | ------------------------------------------------ | ------------------------------------------------------------- |
| Repo root | `CONTRIBUTING.md` (extended) | Contributors cloning the repo | | Repo root | `CONTRIBUTING.md` (extended) | Contributors cloning the repo |
| Docs site | `src/content/docs/contributing/branch-model.mdx` | End users + would-be contributors browsing docs.healthlog.dev | | Docs site | `src/content/docs/contributing/branch-model.mdx` | End users + would-be contributors browsing docs.healthlog.dev |
Both contain: Both contain:
@@ -20,10 +20,10 @@ Both contain:
## Commits ## Commits
| Repo | Commit | Branch | | Repo | Commit | Branch |
|---|---|---| | -------------- | ------------- | --------- |
| HealthLog | (this commit) | `develop` | | HealthLog | (this commit) | `develop` |
| healthlog-docs | `5d96861` | `main` | | healthlog-docs | `5d96861` | `main` |
## Build verification ## Build verification
+14 -14
View File
@@ -9,25 +9,25 @@ manually as four atomic commits.
## Pre-existing compliance pass ## Pre-existing compliance pass
| File | State | | File | State |
|---|---| | ------------------------ | ------------------------------------- |
| `README.md` | Comprehensive; no edits | | `README.md` | Comprehensive; no edits |
| `LICENSE` | AGPL-3.0; no edits | | `LICENSE` | AGPL-3.0; no edits |
| `CONTRIBUTING.md` | Just extended in F2 with branch model | | `CONTRIBUTING.md` | Just extended in F2 with branch model |
| `SECURITY.md` | Current; security@bombeck.io contact | | `SECURITY.md` | Current; security@bombeck.io contact |
| `.gitignore` | Standard Next.js + Node coverage | | `.gitignore` | Standard Next.js + Node coverage |
| `.github/dependabot.yml` | npm only — extended below | | `.github/dependabot.yml` | npm only — extended below |
## Inline fixes (CRITICAL + HIGH) ## Inline fixes (CRITICAL + HIGH)
Four atomic commits on `develop`: Four atomic commits on `develop`:
| Commit | Adds | | Commit | Adds |
|---|---| | --------- | ----------------------------------------------------------------------------------------------------- |
| `e7b6b27` | `CODE_OF_CONDUCT.md` (Contributor Covenant 2.1, conduct@bombeck.io contact) + CONTRIBUTING.md link | | `e7b6b27` | `CODE_OF_CONDUCT.md` (Contributor Covenant 2.1, conduct@bombeck.io contact) + CONTRIBUTING.md link |
| `072941b` | `.github/ISSUE_TEMPLATE/{bug_report,feature_request,config}.yml` + `.github/PULL_REQUEST_TEMPLATE.md` | | `072941b` | `.github/ISSUE_TEMPLATE/{bug_report,feature_request,config}.yml` + `.github/PULL_REQUEST_TEMPLATE.md` |
| `779e5a3` | `.github/dependabot.yml` extended with `github-actions` + `docker` ecosystems | | `779e5a3` | `.github/dependabot.yml` extended with `github-actions` + `docker` ecosystems |
| `f7977d1` | `package.json` metadata: description, license, homepage, repository, bugs, keywords | | `f7977d1` | `package.json` metadata: description, license, homepage, repository, bugs, keywords |
### Why each is non-trivial ### Why each is non-trivial
@@ -66,4 +66,4 @@ repo audit (deferred MED + LOW)". Five items, all LOW:
- `pnpm install` not re-run (no dependency changes) - `pnpm install` not re-run (no dependency changes)
- Pre-commit hooks green on every commit; no `--no-verify`, no - Pre-commit hooks green on every commit; no `--no-verify`, no
`--no-gpg-sign` `--no-gpg-sign`
- No edits to CHANGELOG.md, docs/audit/*, or any source file - No edits to CHANGELOG.md, docs/audit/\*, or any source file
+6 -6
View File
@@ -9,12 +9,12 @@ Dockerfile, docker-compose.yml, and `.github/workflows/docker-publish.yml`.
## Findings ## Findings
| Severity | Count | Status | | Severity | Count | Status |
|---|---|---| | -------- | ----- | ------------------------------ |
| CRITICAL | 2 | fixed inline `19eb8de` | | CRITICAL | 2 | fixed inline `19eb8de` |
| HIGH | 10 | fixed inline `3d3ea21` | | HIGH | 10 | fixed inline `3d3ea21` |
| MED | 9 | deferred to `v1421-backlog.md` | | MED | 9 | deferred to `v1421-backlog.md` |
| LOW | 5 | deferred to `v1421-backlog.md` | | LOW | 5 | deferred to `v1421-backlog.md` |
## Inline fixes ## Inline fixes
+5 -5
View File
@@ -8,11 +8,11 @@ release notes leak personal name + health figures.
## Three repos, three commits ## Three repos, three commits
| Repo | Commit | Files | Diff | | Repo | Commit | Files | Diff |
|---|---|---|---| | --------------------- | -------------------------- | ----- | ----------------------- |
| HealthLog (main repo) | `a1cf9bc` on `origin/main` | 20 | +265 / −269 | | HealthLog (main repo) | `a1cf9bc` on `origin/main` | 20 | +265 / −269 |
| healthlog-docs | `782862b` on `origin/main` | 7 | API examples + DE leaks | | healthlog-docs | `782862b` on `origin/main` | 7 | API examples + DE leaks |
| healthlog-landing | `9b31083` on `origin/main` | 1 | JSON-LD author block | | healthlog-landing | `9b31083` on `origin/main` | 1 | JSON-LD author block |
Three parallel sub-agents executed the scrubs simultaneously; commits Three parallel sub-agents executed the scrubs simultaneously; commits
landed independently. No working-tree races. No `--no-verify`. No landed independently. No working-tree races. No `--no-verify`. No
+27 -19
View File
@@ -5,6 +5,7 @@ Branch: develop (post-v1.4.21)
Production probed: `https://healthlog.bombeck.io` (image digest Production probed: `https://healthlog.bombeck.io` (image digest
`sha256:4e818d44702c…`, v1.4.21) `sha256:4e818d44702c…`, v1.4.21)
Probe scripts (read-only, no commits): Probe scripts (read-only, no commits):
- `.planning/v1422-w1a-tokens-probe.mjs` (admin/api-tokens overflow walk) - `.planning/v1422-w1a-tokens-probe.mjs` (admin/api-tokens overflow walk)
- `.planning/v1422-w1a-insights-probe.mjs` (Insights `Metrik` / token leak hunt) - `.planning/v1422-w1a-insights-probe.mjs` (Insights `Metrik` / token leak hunt)
- `.planning/v1422-w1a-targets-probe.mjs` (Zielwerte page screenshot) - `.planning/v1422-w1a-targets-probe.mjs` (Zielwerte page screenshot)
@@ -38,6 +39,7 @@ takes years of continued discipline to budge.
#### A) Re-anchor headline to last-30d, surface all-time as a sub-value #### A) Re-anchor headline to last-30d, surface all-time as a sub-value
Pro: Pro:
- The dashboard tile becomes a "what's happening now" widget, matching - The dashboard tile becomes a "what's happening now" widget, matching
every other tile on the page (weight, pulse, mood — all show recent every other tile on the page (weight, pulse, mood — all show recent
reading + 7d/30d). The BD tile is currently the only one whose reading + 7d/30d). The BD tile is currently the only one whose
@@ -48,9 +50,10 @@ Pro:
a new field `bpInTargetPctAllTime` for the sub-row. a new field `bpInTargetPctAllTime` for the sub-row.
Con: Con:
- Inverts the v1.4.19 A1 fix story. Need a backlog entry to clarify - Inverts the v1.4.19 A1 fix story. Need a backlog entry to clarify
that the v1.4.19 fix was about *correctness* (the headline was that the v1.4.19 fix was about _correctness_ (the headline was
silently equal to the 30-day) and v1.4.22 is about *framing* (the silently equal to the 30-day) and v1.4.22 is about _framing_ (the
all-time aggregate belongs in the small text). all-time aggregate belongs in the small text).
- Cache invalidation on the `analytics` query (existing TanStack - Cache invalidation on the `analytics` query (existing TanStack
pattern handles this). pattern handles this).
@@ -58,13 +61,15 @@ Con:
#### B) Explicit window label "Allzeit / All time" on the headline #### B) Explicit window label "Allzeit / All time" on the headline
Pro: Pro:
- Smallest diff. Add a `<span className="text-xs text-muted">{t( - Smallest diff. Add a `<span className="text-xs text-muted">{t(
"dashboard.bpInTargetAllTimeLabel")}</span>` next to the 11 %. No "dashboard.bpInTargetAllTimeLabel")}</span>` next to the 11 %. No
data-shape change, no API change. data-shape change, no API change.
- Honours the v1.4.19 A1 contract verbatim — the headline still - Honours the v1.4.19 A1 contract verbatim — the headline still
surfaces the slowest-moving number. surfaces the slowest-moving number.
Con: Con:
- The cognitive dissonance remains: the user still reads the big - The cognitive dissonance remains: the user still reads the big
number first and the label second. A 50 % → 50 % → 11 % "punishment number first and the label second. A 50 % → 50 % → 11 % "punishment
reading" persists; the label only confirms why. reading" persists; the label only confirms why.
@@ -74,14 +79,16 @@ Con:
#### C) Three-window chip row (no headline) — replace big-number layout #### C) Three-window chip row (no headline) — replace big-number layout
Pro: Pro:
- Renders the tile as three equal-weight chips: `7d 50 % · 30d 50 % · - Renders the tile as three equal-weight chips: `7d 50 % · 30d 50 % ·
Allzeit 11 %`. No single number dominates; the user picks the Allzeit 11 %`. No single number dominates; the user picks the
window that matters to them. window that matters to them.
- Visually distinct enough to break the "every TrendCard is identical" - Visually distinct enough to break the "every TrendCard is identical"
uniformity, which signals to the user that BP-in-target is a uniformity, which signals to the user that BP-in-target is a
conceptually different metric than weight / pulse. conceptually different metric than weight / pulse.
Con: Con:
- Largest design lift. Diverges from the shared `<TrendCard>` chrome - Largest design lift. Diverges from the shared `<TrendCard>` chrome
used everywhere else on the dashboard, so either (a) BD tile gets used everywhere else on the dashboard, so either (a) BD tile gets
a bespoke `<BpInTargetTile>` component, or (b) the shared TrendCard a bespoke `<BpInTargetTile>` component, or (b) the shared TrendCard
@@ -116,12 +123,12 @@ priority changes. The fix is roughly:
### Per-viewport probe results ### Per-viewport probe results
| Viewport | viewport×inner | doc overflow | body overflow | Real culprit | | Viewport | viewport×inner | doc overflow | body overflow | Real culprit |
| ------------------- | -------------- | ------------ | ------------- | ------------------------------------------ | | ------------------ | -------------- | ------------ | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| iphone-se 375×667 | 375 / 375 | NO | NO | (none — only the intentional `nav.no-scrollbar` mobile section strip) | | iphone-se 375×667 | 375 / 375 | NO | NO | (none — only the intentional `nav.no-scrollbar` mobile section strip) |
| pixel5 393×851 | 393 / 393 | NO | NO | (none — same intentional nav) | | pixel5 393×851 | 393 / 393 | NO | NO | (none — same intentional nav) |
| ipad-mini 768×1024 | 768 / 768 | NO | NO | `<div class="hidden overflow-x-auto md:block">` (intentional `auto`, BUT triggers because the inner table overflows) — table sw=212, cw=122, delta=90 | | ipad-mini 768×1024 | 768 / 768 | NO | NO | `<div class="hidden overflow-x-auto md:block">` (intentional `auto`, BUT triggers because the inner table overflows) — table sw=212, cw=122, delta=90 |
| desktop 1280×800 | 1280 / 1280 | NO | NO | Same wrapper. Table sw=663, cw=634, delta=29 | | desktop 1280×800 | 1280 / 1280 | NO | NO | Same wrapper. Table sw=663, cw=634, delta=29 |
### Real culprit ### Real culprit
@@ -187,13 +194,13 @@ breakpoint up.
The Playwright probe captured the literal substrings rendered in the The Playwright probe captured the literal substrings rendered in the
production /insights page (German locale, live-tenant data): production /insights page (German locale, live-tenant data):
| Match | Context (rendered prose, last 60 + 60 chars) | | Match | Context (rendered prose, last 60 + 60 chars) |
| --------------------------- | --------------------------------------------------------------------------------------------- | | --------------------------- | ------------------------------------------------------------------------------------------------------- |
| `metric:BLOOD_PRESSURE_SYS` | "…strukturiertes Gegensteuern sinnvoll ist. metric:BLOOD_PRESSURE_SYS\n\nZuletzt aktualisiert" | | `metric:BLOOD_PRESSURE_SYS` | "…strukturiertes Gegensteuern sinnvoll ist. metric:BLOOD_PRESSURE_SYS\n\nZuletzt aktualisiert" |
| `metric:BLOOD_PRESSURE_SYS` | "…Medikation mit niedrigeren systolischen Werten einhergeht. metric:BLOOD_PRESSURE_SYS\n\n…" | | `metric:BLOOD_PRESSURE_SYS` | "…Medikation mit niedrigeren systolischen Werten einhergeht. metric:BLOOD_PRESSURE_SYS\n\n…" |
| `metric:WEIGHT` | "…damit ich die BMI-Klassifikation präzise berechnen kann. metric:WEIGHT\n\nZuletzt aktualisiert" | | `metric:WEIGHT` | "…damit ich die BMI-Klassifikation präzise berechnen kann. metric:WEIGHT\n\nZuletzt aktualisiert" |
| `metric:PULSE` | "…und aktuell, daher ist die Aussage belastbar. metric:PULSE\n\nZuletzt aktualisiert" | | `metric:PULSE` | "…und aktuell, daher ist die Aussage belastbar. metric:PULSE\n\nZuletzt aktualisiert" |
| `metric:MOOD` | "…nur 4 % Zielbereichstreffern in den letzten 30 Tagespunkten. metric:MOOD\n\nZuletzt aktualisiert" | | `metric:MOOD` | "…nur 4 % Zielbereichstreffern in den letzten 30 Tagespunkten. metric:MOOD\n\nZuletzt aktualisiert" |
| `metric:BLOOD_PRESSURE_SYS` | "…regelmäßig nur eine von zwei geplanten Tagesdosen. metric:BLOOD_PRESSURE_SYS\n\nZuletzt aktualisiert" | | `metric:BLOOD_PRESSURE_SYS` | "…regelmäßig nur eine von zwei geplanten Tagesdosen. metric:BLOOD_PRESSURE_SYS\n\nZuletzt aktualisiert" |
The `Zuletzt aktualisiert: …` timestamp tail is the per-section The `Zuletzt aktualisiert: …` timestamp tail is the per-section
@@ -279,7 +286,7 @@ Compared to `/dashboard` and `/insights` the page is visually
**static and reference-y**: **static and reference-y**:
- No 7-day delta. Only the 30-day mean. - No 7-day delta. Only the 30-day mean.
- No sparkline / mini-chart in the card. The user sees the *current* - No sparkline / mini-chart in the card. The user sees the _current_
value but no story of how they got there. value but no story of how they got there.
- No "Δ vs last month" callout (the comparison toggle pattern shipped - No "Δ vs last month" callout (the comparison toggle pattern shipped
in v1.4.16 phase B8 lives only on the dashboard). in v1.4.16 phase B8 lives only on the dashboard).
@@ -441,7 +448,7 @@ complexity bucket below.
Portal container to SheetContent. Portal container to SheetContent.
- **Design-M6** — Tone-bar visual clip at rounded corner. - **Design-M6** — Tone-bar visual clip at rounded corner.
- **Design-M8** — "n=N" reads as engineering output — use `{count} - **Design-M8** — "n=N" reads as engineering output — use `{count}
samples` / `{count} Werte`. samples` / `{count} Werte`.
- **Design-L2** — Health Score sub-bars per-component band. - **Design-L2** — Health Score sub-bars per-component band.
**Wave-4 medium count: 26 items.** Recommend Wave-4 picks the BP-tile **Wave-4 medium count: 26 items.** Recommend Wave-4 picks the BP-tile
@@ -503,6 +510,7 @@ to start with the 13 quick-wins + 8-10 medium picks above.
- **Obsolete to close:** 7 - **Obsolete to close:** 7
Probe artifacts: Probe artifacts:
- `/tmp/v1422-w1a/findings.json` + 4 viewport screenshots - `/tmp/v1422-w1a/findings.json` + 4 viewport screenshots
- `/tmp/v1422-w1a-insights/findings.json` + 3 locale screenshots - `/tmp/v1422-w1a-insights/findings.json` + 3 locale screenshots
- `/tmp/v1422-w1a-targets/desktop.png` + `pixel5.png` - `/tmp/v1422-w1a-targets/desktop.png` + `pixel5.png`
+4 -4
View File
@@ -132,10 +132,10 @@ copy-paste regression can't reintroduce the leak.
## Test delta ## Test delta
| Layer | Before | After | | Layer | Before | After |
| ----------- | ------ | ----- | | ------------- | ---------------------- | ---------------------------------- |
| Unit (vitest) | 2036 tests / 238 files | 2049 tests / 239 files | | Unit (vitest) | 2036 tests / 238 files | 2049 tests / 239 files |
| Integration | (covered separately) | +2 cases in `bp-in-target.test.ts` | | Integration | (covered separately) | +2 cases in `bp-in-target.test.ts` |
13 new unit cases + 2 new integration cases. All green. 13 new unit cases + 2 new integration cases. All green.
+20 -20
View File
@@ -9,15 +9,15 @@ analytics-side sleep-stage aggregation the new schema unblocks.
### Six atomic commits ### Six atomic commits
1. **`feat(schema)` — Apple Health measurement types + sleep stages 1. \*\*`feat(schema)` — Apple Health measurement types + sleep stages
+ dedup index.** `prisma/schema.prisma` gains seven additive - dedup index.\*\* `prisma/schema.prisma` gains seven additive
`MeasurementType` values, `APPLE_HEALTH` joins `MeasurementSource`, `MeasurementType` values, `APPLE_HEALTH` joins `MeasurementSource`,
a new `SleepStage` enum mirrors the existing `GlucoseContext` a new `SleepStage` enum mirrors the existing `GlucoseContext`
pattern, and `Measurement` picks up nullable `sleepStage` + pattern, and `Measurement` picks up nullable `sleepStage` +
`externalSourceVersion` columns plus a composite unique index `externalSourceVersion` columns plus a composite unique index
`(user_id, type, source, external_id)`. Migration `(user_id, type, source, external_id)`. Migration
`0036_apple_health_measurement_types` is strictly additive — no `0036_apple_health_measurement_types` is strictly additive — no
row mutations. row mutations.
2. **`feat(measurements)` — Apple Health identifier mapping helper.** 2. **`feat(measurements)` — Apple Health identifier mapping helper.**
New `src/lib/measurements/apple-health-mapping.ts` translates each New `src/lib/measurements/apple-health-mapping.ts` translates each
HealthKit identifier into the row shape the `Measurement` model HealthKit identifier into the row shape the `Measurement` model
@@ -58,14 +58,14 @@ analytics-side sleep-stage aggregation the new schema unblocks.
- `ALTER TYPE measurement_type ADD VALUE IF NOT EXISTS …` × 7 (the - `ALTER TYPE measurement_type ADD VALUE IF NOT EXISTS …` × 7 (the
seven new HealthKit-shaped values). seven new HealthKit-shaped values).
- `ALTER TYPE measurement_source ADD VALUE IF NOT EXISTS - `ALTER TYPE measurement_source ADD VALUE IF NOT EXISTS
'APPLE_HEALTH'`. 'APPLE_HEALTH'`.
- `CREATE TYPE sleep_stage AS ENUM (…)`. - `CREATE TYPE sleep_stage AS ENUM (…)`.
- `ALTER TABLE measurements ADD COLUMN external_source_version - `ALTER TABLE measurements ADD COLUMN external_source_version
TEXT, ADD COLUMN sleep_stage sleep_stage` (both nullable). TEXT, ADD COLUMN sleep_stage sleep_stage` (both nullable).
- CHECK constraint scoping `sleep_stage` to SLEEP_DURATION rows - CHECK constraint scoping `sleep_stage` to SLEEP_DURATION rows
(mirrors the glucose_context constraint from migration 0021). (mirrors the glucose_context constraint from migration 0021).
- `CREATE UNIQUE INDEX measurements_user_id_type_source_external_id_key - `CREATE UNIQUE INDEX measurements_user_id_type_source_external_id_key
ON measurements (user_id, type, source, external_id)` — Apple ON measurements (user_id, type, source, external_id)` — Apple
Health batch dedup key. Postgres treats NULL externalId rows as Health batch dedup key. Postgres treats NULL externalId rows as
distinct, so manual entries don't collide. distinct, so manual entries don't collide.
@@ -75,15 +75,15 @@ guard.
## W1 recommendations adopted verbatim ## W1 recommendations adopted verbatim
| W1 recommendation | Status | | W1 recommendation | Status |
|---|---| | ----------------------------------------------------------- | ------- |
| Source enum value = `APPLE_HEALTH` (not `HEALTHKIT`) | Adopted | | Source enum value = `APPLE_HEALTH` (not `HEALTHKIT`) | Adopted |
| Reuse existing `ACTIVITY_STEPS` for HK step count | Adopted | | Reuse existing `ACTIVITY_STEPS` for HK step count | Adopted |
| Reuse existing `SLEEP_DURATION`, shift unit hours → minutes | Adopted | | Reuse existing `SLEEP_DURATION`, shift unit hours → minutes | Adopted |
| Per-stage sleep rows with new `sleep_stage` column | Adopted | | Per-stage sleep rows with new `sleep_stage` column | Adopted |
| `apple-health-mapping.ts` keyed by HK string identifier | Adopted | | `apple-health-mapping.ts` keyed by HK string identifier | Adopted |
| Apple ships 0..1 fraction for SpO2 + body fat → × 100 | Adopted | | Apple ships 0..1 fraction for SpO2 + body fat → × 100 | Adopted |
| `BODY_TEMPERATURE` enum value alongside the other 6 | Adopted | | `BODY_TEMPERATURE` enum value alongside the other 6 | Adopted |
## Decisions made beyond W1's scope ## Decisions made beyond W1's scope
+2 -2
View File
@@ -15,11 +15,11 @@ of every assistant turn. Five atomic commits on `origin/develop`.
extension, route + persistence + SSE provenance frame wiring, extension, route + persistence + SSE provenance frame wiring,
integration coverage. integration coverage.
3. `feat(coach): collapsible evidence-block disclosure under assistant 3. `feat(coach): collapsible evidence-block disclosure under assistant
bubbles` — `<details>` disclosure with chevron in bubbles` — `<details>` disclosure with chevron in
`message-thread.tsx`; closed by default; hides when keyValues is `message-thread.tsx`; closed by default; hides when keyValues is
empty; both EN + DE labels. empty; both EN + DE labels.
4. `refactor(coach): drawer polish — Gravatar avatar, disclaimer 4. `refactor(coach): drawer polish — Gravatar avatar, disclaimer
move, cog removal` — user bubble pulls `useAuth().user.gravatarUrl` move, cog removal` — user bubble pulls `useAuth().user.gravatarUrl`
at the same 32-px dimensions as the Coach avatar with initials at the same 32-px dimensions as the Coach avatar with initials
fallback; disclaimer moved out of `coach-input.tsx` into fallback; disclaimer moved out of `coach-input.tsx` into
`sources-rail.tsx`'s footer; settings cog removed cleanly from the `sources-rail.tsx`'s footer; settings cog removed cleanly from the
+18 -18
View File
@@ -99,30 +99,30 @@ could fail on existing data, no enum reorderings.
`pnpm-lock.yaml` adds `@parse/node-apn@8.1.0` and the documented `pnpm-lock.yaml` adds `@parse/node-apn@8.1.0` and the documented
transitive dependency chain only: transitive dependency chain only:
| package | reason | | package | reason |
|---|---| | ------------------------------------------------------------ | ----------------------------------- |
| `@parse/node-apn` | direct dependency | | `@parse/node-apn` | direct dependency |
| `jsonwebtoken` | JWT bearer for APNs auth tokens | | `jsonwebtoken` | JWT bearer for APNs auth tokens |
| `node-forge` | PEM parsing for the .p8 signing key | | `node-forge` | PEM parsing for the .p8 signing key |
| `verror` | error wrapping inside node-apn | | `verror` | error wrapping inside node-apn |
| `assert-plus`, `core-util-is`, `extsprintf` | verror's transitive set | | `assert-plus`, `core-util-is`, `extsprintf` | verror's transitive set |
| `lodash.includes`, `lodash.isboolean`, `lodash.isinteger`, … | jsonwebtoken's per-helper imports | | `lodash.includes`, `lodash.isboolean`, `lodash.isinteger`, … | jsonwebtoken's per-helper imports |
`web-push` already pulls `node-forge` for VAPID JWT signing, so `web-push` already pulls `node-forge` for VAPID JWT signing, so
the on-disk install footprint delta is small. the on-disk install footprint delta is small.
## W1 recommendations adopted verbatim ## W1 recommendations adopted verbatim
| W1 recommendation | Status | | W1 recommendation | Status |
|---|---| | -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- |
| Library = `@parse/node-apn` (vs `apns2` / hand-rolled HTTP/2) | Adopted | | Library = `@parse/node-apn` (vs `apns2` / hand-rolled HTTP/2) | Adopted |
| APNs joins existing `NotificationChannel` enum as 4th type | Adopted | | APNs joins existing `NotificationChannel` enum as 4th type | Adopted |
| Reuse existing `consecutiveFailures` / `nextRetryAt` / `disabledReason` columns | Adopted | | Reuse existing `consecutiveFailures` / `nextRetryAt` / `disabledReason` columns | Adopted |
| Per-device `apnsEnvironment` (`sandbox` \| `production`); no server-side auto-detect | Adopted | | Per-device `apnsEnvironment` (`sandbox` \| `production`); no server-side auto-detect | Adopted |
| Env-var contract `APNS_KEY_ID` / `APNS_TEAM_ID` / `APNS_BUNDLE_ID` / one of `APNS_KEY` / `APNS_KEY_FILE` | Adopted | | Env-var contract `APNS_KEY_ID` / `APNS_TEAM_ID` / `APNS_BUNDLE_ID` / one of `APNS_KEY` / `APNS_KEY_FILE` | Adopted |
| Provider singleton per process — lazy-init, JWT rotated by library | Adopted | | Provider singleton per process — lazy-init, JWT rotated by library | Adopted |
| Permanent failures (`Unregistered`, `BadDeviceToken`) drop the device row | Adopted, plus `DeviceTokenNotForTopic` for completeness | | Permanent failures (`Unregistered`, `BadDeviceToken`) drop the device row | Adopted, plus `DeviceTokenNotForTopic` for completeness |
| Mock provider for test isolation | Adopted (`__mocks__/apns.ts` + `__tests__/apns.test.ts`) | | Mock provider for test isolation | Adopted (`__mocks__/apns.ts` + `__tests__/apns.test.ts`) |
## Decisions made beyond W1's scope ## Decisions made beyond W1's scope
+8 -8
View File
@@ -7,14 +7,14 @@ commits preserved)
## Six commits shipped ## Six commits shipped
| # | Subject | Verdict | | # | Subject | Verdict |
| - | ------------------------------------------------------------------------------------ | --------- | | --- | ----------------------------------------------------------------------------------- | ------- |
| 1 | feat(targets): 30-day sparkline + Δ-vs-last-month caption per card | shipped | | 1 | feat(targets): 30-day sparkline + Δ-vs-last-month caption per card | shipped |
| 2 | fix(admin): drop whitespace-nowrap on api-tokens date cells (5th attempt) | shipped | | 2 | fix(admin): drop whitespace-nowrap on api-tokens date cells (5th attempt) | shipped |
| 3 | ops(deploy): force=true on Coolify webhook + node-26-alpine investigation | shipped | | 3 | ops(deploy): force=true on Coolify webhook + node-26-alpine investigation | shipped |
| 4 | fix(auth): move onboarding redirect from AuthShell useEffect to proxy | shipped | | 4 | fix(auth): move onboarding redirect from AuthShell useEffect to proxy | shipped |
| 5 | i18n+ui: backlog quick-wins sweep (F-32/33/34/35/44/45/47/50/58/62 + 2 mediums) | shipped | | 5 | i18n+ui: backlog quick-wins sweep (F-32/33/34/35/44/45/47/50/58/62 + 2 mediums) | shipped |
| 6 | chore: voice + filename hygiene (Marc sweep, bilingual CHANGELOG, CLAUDE.md rename) | shipped | | 6 | chore: voice + filename hygiene (Marc sweep, bilingual CHANGELOG, CLAUDE.md rename) | shipped |
## C1 — Zielwerte sparkline + delta ## C1 — Zielwerte sparkline + delta
+15 -14
View File
@@ -45,13 +45,13 @@ log line).
the wider user-wide revoke (safety hatch). The two-device case the wider user-wide revoke (safety hatch). The two-device case
(iPad + iPhone) now works as expected. (iPad + iPhone) now works as expected.
6. **`feat(api)` — device management endpoints.** Three new routes: 6. **`feat(api)` — device management endpoints.** Three new routes:
- `GET /api/auth/me/devices` lists devices with label, - `GET /api/auth/me/devices` lists devices with label,
lastSeen, channels (`web_push` / `apns`), `isCurrent` lastSeen, channels (`web_push` / `apns`), `isCurrent`
- `DELETE /api/auth/me/devices/[id]` revokes a device + the - `DELETE /api/auth/me/devices/[id]` revokes a device + the
refresh + access tokens bound to it + deletes the row refresh + access tokens bound to it + deletes the row
- `DELETE /api/devices/[id]` is the native-friendly mirror the - `DELETE /api/devices/[id]` is the native-friendly mirror the
iOS APNs-rotation flow calls iOS APNs-rotation flow calls
Cross-user attempts return 404 with no enumeration leak. Cross-user attempts return 404 with no enumeration leak.
7. **`ci(deploy)` — Coolify auto-deploy runbook.** The workflow 7. **`ci(deploy)` — Coolify auto-deploy runbook.** The workflow
already pinged `?force=true` (v1.4.22 C3 commit `b281c06`); W4 already pinged `?force=true` (v1.4.22 C3 commit `b281c06`); W4
adds a `::notice::` line so future Actions runs surface the adds a `::notice::` line so future Actions runs surface the
@@ -82,6 +82,7 @@ row and `diff`-ing the outputs — no drift.
### New dependencies ### New dependencies
Two, exactly per the brief: Two, exactly per the brief:
- `zod-openapi@5.4.6` — Zod v4 `.meta()` reader, ZodOpenApi document - `zod-openapi@5.4.6` — Zod v4 `.meta()` reader, ZodOpenApi document
builder. builder.
- `yaml@2.8.4` — stable map-sorted YAML serialiser. - `yaml@2.8.4` — stable map-sorted YAML serialiser.
@@ -94,13 +95,13 @@ change is code-only.
## W1 decisions adopted verbatim ## W1 decisions adopted verbatim
| W1 recommendation | Status | | W1 recommendation | Status |
|---|---| | -------------------------------------------------------------------- | ------- |
| OpenAPI tool = `zod-openapi` (samchungy) | Adopted | | OpenAPI tool = `zod-openapi` (samchungy) | Adopted |
| YAML normalisation via `yaml@^2` + `sortMapEntries: true` | Adopted | | YAML normalisation via `yaml@^2` + `sortMapEntries: true` | Adopted |
| Two-stage migration (subset start + organic catch-up) | Adopted | | Two-stage migration (subset start + organic catch-up) | Adopted |
| CI step `continue-on-error: true` for v1.4.23, hard-fail in v1.4.24+ | Adopted | | CI step `continue-on-error: true` for v1.4.23, hard-fail in v1.4.24+ | Adopted |
| `oasdiff` adoption deferred to v1.4.24 | Adopted | | `oasdiff` adoption deferred to v1.4.24 | Adopted |
## Decisions made beyond W1's scope ## Decisions made beyond W1's scope
@@ -181,7 +182,7 @@ device-management surface:
medicationId in the collapseId — confirm before v1.5 ships. medicationId in the collapseId — confirm before v1.5 ships.
11. **NEW W4 — device-list rendering on the iOS settings surface.** 11. **NEW W4 — device-list rendering on the iOS settings surface.**
The `GET /api/auth/me/devices` response carries `channels: The `GET /api/auth/me/devices` response carries `channels:
["web_push" | "apns"]` per device. Confirm the iOS UI either ["web_push" | "apns"]` per device. Confirm the iOS UI either
renders both as chips or filters to the channel matching the renders both as chips or filters to the channel matching the
current platform; the iOS app shouldn't render `web_push` current platform; the iOS app shouldn't render `web_push`
chips against an iPhone row. chips against an iPhone row.
+15 -12
View File
@@ -13,18 +13,18 @@ Inputs: six W5 reviews + product-lead strategic memo.
## Commits ## Commits
| # | SHA | Title | Cross-ref | | # | SHA | Title | Cross-ref |
|---|-----|-------|-----------| | --- | ------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------- |
| 1 | `75ccc01` | fix(coach): graceful fallback when sentinel-only output strips empty | Code-H1 | | 1 | `75ccc01` | fix(coach): graceful fallback when sentinel-only output strips empty | Code-H1 |
| 2 | `ef597bf` | fix(insights): align BD-Zielbereich delta math with comparison window | Code-H2 | | 2 | `ef597bf` | fix(insights): align BD-Zielbereich delta math with comparison window | Code-H2 |
| 3 | `b1ce6eb` | refactor(auth): fold onboarding-cookie write into createSession + destroySession | Sr-H1 | | 3 | `b1ce6eb` | refactor(auth): fold onboarding-cookie write into createSession + destroySession | Sr-H1 |
| 4 | `2075d39` | refactor(api): extract createSseStream helper from chat route | Sr-H2 | | 4 | `2075d39` | refactor(api): extract createSseStream helper from chat route | Sr-H2 |
| 5 | `a2e24b1` | a11y(insights): polish sticky section nav (aria-current, focus, motion-reduce) | Design-H1, Design-H3, Code-MED-4, Design-LOW-4 | | 5 | `a2e24b1` | a11y(insights): polish sticky section nav (aria-current, focus, motion-reduce) | Design-H1, Design-H3, Code-MED-4, Design-LOW-4 |
| 6 | `651a529` | fix(insights): collapse BP tile to single secondary row at <sm | Design-H2 | | 6 | `651a529` | fix(insights): collapse BP tile to single secondary row at <sm | Design-H2 |
| 7 | `65a8b5a` | fix(coach): pin medical disclaimer at bottom of message thread | Design-H3, Design-M4 | | 7 | `65a8b5a` | fix(coach): pin medical disclaimer at bottom of message thread | Design-H3, Design-M4 |
| 8 | `1631d80` | refactor: simplify-pass apply-yes cleanup | S-01..S-05 | | 8 | `1631d80` | refactor: simplify-pass apply-yes cleanup | S-01..S-05 |
| 9 | `6c0c69c` | fix: MED-cluster — SameSite=Strict, exact-match PUBLIC_PATHS, Berlin tz, streaming race | Sec-MED-1, Sec-MED-2, Code-MED-2, Code-MED-3 (×2), Code-LOW-5 | | 9 | `6c0c69c` | fix: MED-cluster — SameSite=Strict, exact-match PUBLIC_PATHS, Berlin tz, streaming race | Sec-MED-1, Sec-MED-2, Code-MED-2, Code-MED-3 (×2), Code-LOW-5 |
| 10 | _this commit_ | docs(planning): record Wave 5 reconcile + v1.4.22 backlog | meta | | 10 | _this commit_ | docs(planning): record Wave 5 reconcile + v1.4.22 backlog | meta |
## Findings by bucket ## Findings by bucket
@@ -73,6 +73,7 @@ All LOWs deferred to backlog: Code-LOW-1..LOW-4, Sec-LOW-1..LOW-4, Design-L1..L5
- Integration tests: 81 → 84 (+3) per the new auth-flow + coach-chat + proxy entries; verified locally (`pnpm test:integration auth-flow` passes 6/6). - Integration tests: 81 → 84 (+3) per the new auth-flow + coach-chat + proxy entries; verified locally (`pnpm test:integration auth-flow` passes 6/6).
New test surfaces: New test surfaces:
- `src/lib/sse/__tests__/create-stream.test.ts` (3 tests) - `src/lib/sse/__tests__/create-stream.test.ts` (3 tests)
- `src/lib/analytics/__tests__/berlin-day.test.ts` (4 tests) - `src/lib/analytics/__tests__/berlin-day.test.ts` (4 tests)
- `src/lib/analytics/__tests__/bp-in-target.test.ts` (3 new tests for priorMonth/priorYear/null windows) - `src/lib/analytics/__tests__/bp-in-target.test.ts` (3 new tests for priorMonth/priorYear/null windows)
@@ -84,11 +85,13 @@ New test surfaces:
## Verification gates run between commits ## Verification gates run between commits
For every commit: For every commit:
- `pnpm typecheck` (clean — 0 errors) - `pnpm typecheck` (clean — 0 errors)
- `pnpm test --run` (passes after each change; final 2111/2111) - `pnpm test --run` (passes after each change; final 2111/2111)
- `pnpm lint` (15 pre-existing warnings, 0 errors) - `pnpm lint` (15 pre-existing warnings, 0 errors)
For HIGH 1 + HIGH 3 + Sec-MED-2 (per brief): For HIGH 1 + HIGH 3 + Sec-MED-2 (per brief):
- `pnpm test:integration auth-flow` 6/6 passes - `pnpm test:integration auth-flow` 6/6 passes
- `pnpm test --run src/__tests__/proxy-onboarding-redirect.test.ts` 9/9 passes - `pnpm test --run src/__tests__/proxy-onboarding-redirect.test.ts` 9/9 passes
+7 -7
View File
@@ -7,21 +7,21 @@ clean, all unit + drift gates green.
## Commits ## Commits
1. `58ae9bc` — `feat(coach): partial-malformed observability on 1. `58ae9bc` — `feat(coach): partial-malformed observability on
sentinel parser` (H1) sentinel parser` (H1)
2. `fa07748` — `perf(api): chunked BP aggregate replaces unbounded 2. `fa07748` — `perf(api): chunked BP aggregate replaces unbounded
findMany` (H2) findMany` (H2)
3. `9413d29` — `refactor(coach): drawer prefill becomes a controlled 3. `9413d29` — `refactor(coach): drawer prefill becomes a controlled
prop` (H3) prop` (H3)
4. `3f60c81` — `feat(coach): per-user prompt-tuning surface (settings 4. `3f60c81` — `feat(coach): per-user prompt-tuning surface (settings
cog returns)` (H4 — prisma + route + UI) cog returns)` (H4 — prisma + route + UI)
5. `0eda1de` — `chore(schema): deploy medication_schedules.days_of_week 5. `0eda1de` — `chore(schema): deploy medication_schedules.days_of_week
column` (H5) column` (H5)
6. `1faee95` — `fix(insights): tighter Pearson surfacing gate (n>=20)` 6. `1faee95` — `fix(insights): tighter Pearson surfacing gate (n>=20)`
(H6) (H6)
7. `05c7f14` — `feat(coach): per-message thumbs feedback + admin 7. `05c7f14` — `feat(coach): per-message thumbs feedback + admin
aggregate view` (H7 — schema + route + UI) aggregate view` (H7 — schema + route + UI)
8. (this commit) — `docs(planning): record W5 hygiene wave + STATE 8. (this commit) — `docs(planning): record W5 hygiene wave + STATE
tick` tick`
## H5 decision ## H5 decision
+8 -7
View File
@@ -15,7 +15,7 @@ component edit.
(`src/components/admin/user-management-section.tsx`). Spot (`src/components/admin/user-management-section.tsx`). Spot
check during Wave B showed every icon button already carries check during Wave B showed every icon button already carries
`aria-label` + `title` (edit, reset password, force logout, `aria-label` + `title` (edit, reset password, force logout,
role change). Treat as *not-an-issue*; revisit only if a role change). Treat as _not-an-issue_; revisit only if a
concrete a11y audit re-flags it. concrete a11y audit re-flags it.
- **F-14** — Settings/Integrations status duplicated. Already - **F-14** — Settings/Integrations status duplicated. Already
shipped under v1.4.19 A5; keep an eye out for regressions but shipped under v1.4.19 A5; keep an eye out for regressions but
@@ -24,7 +24,7 @@ component edit.
every mood label (`mood.levelLausig` … `mood.moodLabel5`, every mood label (`mood.levelLausig` … `mood.moodLabel5`,
`mood.moodAwful` … `mood.moodGreat`) is translated in `mood.moodAwful` … `mood.moodGreat`) is translated in
`messages/de.json`. The achievement-subtitle awkwardness was `messages/de.json`. The achievement-subtitle awkwardness was
fixed by F-27. Treat as *not-an-issue*; revisit only with a fixed by F-27. Treat as _not-an-issue_; revisit only with a
fresh DE-locale screen-by-screen pass. fresh DE-locale screen-by-screen pass.
- **F-26** — Insights regenerate banner / refresh button conflict. - **F-26** — Insights regenerate banner / refresh button conflict.
Already partially in A3 scope (page-level Regenerate, Already partially in A3 scope (page-level Regenerate,
@@ -39,7 +39,7 @@ component edit.
- **F-32** — `/settings/dashboard` triple help blocks - **F-32** — `/settings/dashboard` triple help blocks
(subtitle + card help + "Adds a dimmed prior-period overlay…"). (subtitle + card help + "Adds a dimmed prior-period overlay…").
`messages/*.json:settings.sections.dashboard.description` `messages/*.json:settings.sections.dashboard.description`
+ `dashboardLayout.compareCallout`. **S**. - `dashboardLayout.compareCallout`. **S**.
- **F-39** — `/settings/dashboard` column heads `TILE` / `CHART` - **F-39** — `/settings/dashboard` column heads `TILE` / `CHART`
in uppercase (`src/components/settings/dashboard-layout-section.tsx`). in uppercase (`src/components/settings/dashboard-layout-section.tsx`).
Sentence-case to match the rest of the app. **S**. Sentence-case to match the rest of the app. **S**.
@@ -214,7 +214,7 @@ component edit.
`data?.`) so the rest of the body can drop the redundant `data?.`) so the rest of the body can drop the redundant
operators. Touches lots of references but is mechanical. operators. Touches lots of references but is mechanical.
`src/app/insights/page.tsx:756,758,1036,1046,1064-1065,1235,1245, `src/app/insights/page.tsx:756,758,1036,1046,1064-1065,1235,1245,
1248-1252,1263-1273`. Owner: v1.4.20 Insights redesign. 1248-1252,1263-1273`. Owner: v1.4.20 Insights redesign.
- **D-DSGN-H-01** — Truncate-with-tooltip on `/admin/api-tokens` is - **D-DSGN-H-01** — Truncate-with-tooltip on `/admin/api-tokens` is
not reachable on touch (Radix Tooltip is hover/focus only, native not reachable on touch (Radix Tooltip is hover/focus only, native
`title=` is iOS-Safari-ignored). Drop `truncate` on the mobile `title=` is iOS-Safari-ignored). Drop `truncate` on the mobile
@@ -233,6 +233,7 @@ component edit.
`src/components/settings/integrations-section.tsx:199-531,533-831`. `src/components/settings/integrations-section.tsx:199-531,533-831`.
### MED deferred (code-review M-01..M-07, senior-dev M-1..M-5, ### MED deferred (code-review M-01..M-07, senior-dev M-1..M-5,
design M-01..M-04) design M-01..M-04)
- **D-CR-M-01** — `/insights` per-section status queries carry - **D-CR-M-01** — `/insights` per-section status queries carry
@@ -286,7 +287,7 @@ design M-01..M-04)
to the SectionFrame `<h1>API Tokens</h1>`. F-08 sweep cleared to the SectionFrame `<h1>API Tokens</h1>`. F-08 sweep cleared
the same dup on `/admin/danger-zone` + `/admin/feedback` but the same dup on `/admin/danger-zone` + `/admin/feedback` but
deferred this one. `src/app/admin/[section]/renderer.tsx:123-131, deferred this one. `src/app/admin/[section]/renderer.tsx:123-131,
180-192` + `src/components/admin/api-token-overview-section.tsx:101-106`. 180-192` + `src/components/admin/api-token-overview-section.tsx:101-106`.
- **D-DSGN-M-02** — Profile DOB still wrapped in a - **D-DSGN-M-02** — Profile DOB still wrapped in a
`grid sm:grid-cols-2` with one cell, leaving an empty right `grid sm:grid-cols-2` with one cell, leaving an empty right
column on `>=sm`. Replace with `sm:max-w-md`. column on `>=sm`. Replace with `sm:max-w-md`.
@@ -313,7 +314,7 @@ design M-01..M-04)
may clip seconds at 1024 px (drop seconds in the formatter), may clip seconds at 1024 px (drop seconds in the formatter),
IntegrationStatusPill aria-label generic across states. IntegrationStatusPill aria-label generic across states.
- **D-SR-L-1..L-8** — `ai-section.tsx` (1730 lines), `health- - **D-SR-L-1..L-8** — `ai-section.tsx` (1730 lines), `health-
chart.tsx` (1360), `integrations-section.tsx` (831) split chart.tsx` (1360), `integrations-section.tsx` (831) split
candidates; Telegram badge concat could reuse the candidates; Telegram badge concat could reuse the
IntegrationStatusPill with a `paused` variant; `getViewportWidth` IntegrationStatusPill with a `paused` variant; `getViewportWidth`
SSR fallback `1280` should reference a named constant. SSR fallback `1280` should reference a named constant.
@@ -334,7 +335,7 @@ design M-01..M-04)
## Notes for v1.4.20 picks ## Notes for v1.4.20 picks
- The MED block is dominated by copy nits — running prettier - The MED block is dominated by copy nits — running prettier
+ a single i18n sweep would land 12+ of them in one commit. - a single i18n sweep would land 12+ of them in one commit.
- F-36 (status-word taxonomy) is a small refactor that pays - F-36 (status-word taxonomy) is a small refactor that pays
off everywhere; consider doing it before the Insights off everywhere; consider doing it before the Insights
redesign so the new surface inherits the canonical redesign so the new surface inherits the canonical
+8 -1
View File
@@ -19,6 +19,7 @@ This is the prompt to paste into a fresh Claude Code session to start the v1.4.2
These foundation tasks **MUST** happen first in the session before any feature work: These foundation tasks **MUST** happen first in the session before any feature work:
### F1 — Branch model: introduce long-lived `develop` branch ### F1 — Branch model: introduce long-lived `develop` branch
- Create `develop` from current `main` HEAD; push; set as default work-target - Create `develop` from current `main` HEAD; push; set as default work-target
- All v1.4.20 feature/fix/test work commits to `develop`, NOT `main` - All v1.4.20 feature/fix/test work commits to `develop`, NOT `main`
- `main` reserved for release-merges + tags + GHCR builds (no more daily image churn) - `main` reserved for release-merges + tags + GHCR builds (no more daily image churn)
@@ -27,12 +28,14 @@ These foundation tasks **MUST** happen first in the session before any feature w
- Memory: see `feedback_branch_model_dev_main.md` - Memory: see `feedback_branch_model_dev_main.md`
### F2 — Document the branch + release model ### F2 — Document the branch + release model
- Add `CONTRIBUTING.md` at repo root: branch flow, PR conventions, release process - Add `CONTRIBUTING.md` at repo root: branch flow, PR conventions, release process
- Add small Mermaid or ASCII diagram of branch flow (develop → main → tag → deploy; hotfix from main → both) - Add small Mermaid or ASCII diagram of branch flow (develop → main → tag → deploy; hotfix from main → both)
- Add `docs/contributing.md` on the docs site mirroring the explanation for end-users / would-be contributors - Add `docs/contributing.md` on the docs site mirroring the explanation for end-users / would-be contributors
- Make clear: end users follow `main` / latest tag (stable), contributors look at `develop` - Make clear: end users follow `main` / latest tag (stable), contributors look at `develop`
### F3 — Repo-cleanup audit (retroactive) ### F3 — Repo-cleanup audit (retroactive)
- Multi-agent sweep across the repo for internal jargon leaking into user-facing artifacts: - Multi-agent sweep across the repo for internal jargon leaking into user-facing artifacts:
- `CHANGELOG.md`, `docs/audit/v*-summary.md`, `docs/`, `README.md`, healthlog-docs site, healthlog-landing site, anywhere `Phase X` / `Wave Y` / `marathon` / `sub-agent` / `autonomous orchestrator` / "Claude" name leaked - `CHANGELOG.md`, `docs/audit/v*-summary.md`, `docs/`, `README.md`, healthlog-docs site, healthlog-landing site, anywhere `Phase X` / `Wave Y` / `marathon` / `sub-agent` / `autonomous orchestrator` / "Claude" name leaked
- Replace with neutral language. Keep "Co-Authored-By: Claude Opus 4.7" trailer on commits (git-meta, not prose) - Replace with neutral language. Keep "Co-Authored-By: Claude Opus 4.7" trailer on commits (git-meta, not prose)
@@ -40,17 +43,20 @@ These foundation tasks **MUST** happen first in the session before any feature w
- Constraint: do NOT rewrite git history. Only edit current state of files. - Constraint: do NOT rewrite git history. Only edit current state of files.
### F4 — Release-notes language audit ### F4 — Release-notes language audit
- Marc spotted German strings leaking into English-only release notes - Marc spotted German strings leaking into English-only release notes
- Audit `CHANGELOG.md` + GitHub releases v1.4.14–v1.4.19 + docs/audit/v*-summary.md (where future ones still pending) - Audit `CHANGELOG.md` + GitHub releases v1.4.14–v1.4.19 + docs/audit/v\*-summary.md (where future ones still pending)
- Translate any German leaks to English - Translate any German leaks to English
### F5 — Best-practice GitHub repo audit ### F5 — Best-practice GitHub repo audit
- 2 parallel agents survey the repo structure - 2 parallel agents survey the repo structure
- Compare against best-practice OSS repos: README, LICENSE, CONTRIBUTING.md, CODE_OF_CONDUCT.md, SECURITY.md, .github/ISSUE_TEMPLATE, .github/PULL_REQUEST_TEMPLATE.md, GitHub badges, etc. - Compare against best-practice OSS repos: README, LICENSE, CONTRIBUTING.md, CODE_OF_CONDUCT.md, SECURITY.md, .github/ISSUE_TEMPLATE, .github/PULL_REQUEST_TEMPLATE.md, GitHub badges, etc.
- Identify: files that don't belong, files missing, layout improvements - Identify: files that don't belong, files missing, layout improvements
- Output: prioritized list, apply CRITICAL/HIGH inline, defer rest to v1.4.21 - Output: prioritized list, apply CRITICAL/HIGH inline, defer rest to v1.4.21
### F6 — Multi-agent QA on the new docs (no hallucinations) ### F6 — Multi-agent QA on the new docs (no hallucinations)
- After F2 docs landed: separate agents read the dev-vs-prod docs and verify they describe the actual deployed state — no incorrect claims - After F2 docs landed: separate agents read the dev-vs-prod docs and verify they describe the actual deployed state — no incorrect claims
- Flag anything inaccurate; fix before tagging - Flag anything inaccurate; fix before tagging
@@ -113,6 +119,7 @@ Speed wichtig — autonom über die Nacht / Tage. Bei Fragen: dispatchen statt f
## End state ## End state
When v1.4.20 ships: When v1.4.20 ships:
- v1.4.20 live with the redesigned `/insights` + AI Coach - v1.4.20 live with the redesigned `/insights` + AI Coach
- `develop` branch established as the daily work-target - `develop` branch established as the daily work-target
- CONTRIBUTING.md documents the model - CONTRIBUTING.md documents the model
+6 -7
View File
@@ -111,9 +111,9 @@ HIGH and CRITICAL items all landed inline (fix counts in
`streamRefusal()`. Same broader gap covered by Senior-MED-3. `streamRefusal()`. Same broader gap covered by Senior-MED-3.
- **Refusal patterns miss common injection synonyms** (Sec-M-3). - **Refusal patterns miss common injection synonyms** (Sec-M-3).
Extend `INJECTION_PATTERNS` with `(forget|abandon|drop|skip|toss| Extend `INJECTION_PATTERNS` with `(forget|abandon|drop|skip|toss|
bypass|circumvent)` paired with `(instructions?|rules?|prompt| bypass|circumvent)` paired with `(instructions?|rules?|prompt|
guidelines?|directives?|system\s+message)`, plus a "what guidelines?|directives?|system\s+message)`, plus a "what
(were|are)\s+your.*(instructions|prompt|rules)" probe. Add a (were|are)\s+your.\*(instructions|prompt|rules)" probe. Add a
zero-width-stripping normalisation pass before regex. zero-width-stripping normalisation pass before regex.
- **`assistantMessage` persisted before `recordSpend()`** (Sec-M-4). - **`assistantMessage` persisted before `recordSpend()`** (Sec-M-4).
Wrap the `appendMessage(assistant)` + `recordSpend()` pair in a Wrap the `appendMessage(assistant)` + `recordSpend()` pair in a
@@ -138,7 +138,7 @@ HIGH and CRITICAL items all landed inline (fix counts in
- **Hero strip greeting clock is wall-clock, not Berlin** (Design-M1). - **Hero strip greeting clock is wall-clock, not Berlin** (Design-M1).
Pass the current Berlin hour through `Intl.DateTimeFormat("en-CA", Pass the current Berlin hour through `Intl.DateTimeFormat("en-CA",
{ timeZone: "Europe/Berlin", hour: "numeric" })` so a traveller { timeZone: "Europe/Berlin", hour: "numeric" })` so a traveller
sees the Berlin bucket the rest of the UI uses. sees the Berlin bucket the rest of the UI uses.
- **Health Score panel mobile order inversion** (Design-M3). On `<lg` - **Health Score panel mobile order inversion** (Design-M3). On `<lg`
promote the Health Score panel between the meta row and the weekly promote the Health Score panel between the meta row and the weekly
@@ -201,7 +201,7 @@ HIGH and CRITICAL items all landed inline (fix counts in
- **`apiHandler`-vs-`withIdempotency` stacking** (Sr-MED-6). Largely - **`apiHandler`-vs-`withIdempotency` stacking** (Sr-MED-6). Largely
moot now that the chat route doesn't wrap in idempotency, but still moot now that the chat route doesn't wrap in idempotency, but still
worth offering `withIdempotency(handler, { skipWhen?: (req) => worth offering `withIdempotency(handler, { skipWhen?: (req) =>
boolean })` for the next caller that wants to opt out conditionally. boolean })` for the next caller that wants to opt out conditionally.
- **`coach_usage` redundant index** (Sr-LOW-1). Drop the `@@index` - **`coach_usage` redundant index** (Sr-LOW-1). Drop the `@@index`
line — the `@@unique` already builds an index. line — the `@@unique` already builds an index.
- **`metricSourceJson` stored as `String`** (Sr-LOW-2). Migrate to - **`metricSourceJson` stored as `String`** (Sr-LOW-2). Migrate to
@@ -227,7 +227,6 @@ HIGH and CRITICAL items all landed inline (fix counts in
three sibling `&&` blocks into a `deltaIcon` lookup. Borderline — three sibling `&&` blocks into a `deltaIcon` lookup. Borderline —
judgement call for the file's owner. judgement call for the file's owner.
## F5 — Best-practice repo audit (deferred MED + LOW) ## F5 — Best-practice repo audit (deferred MED + LOW)
- **README badges** (LOW). Add a small badge row: build status (GitHub - **README badges** (LOW). Add a small badge row: build status (GitHub
@@ -282,7 +281,7 @@ are MED + LOW and were deliberately left for a hygiene pass.
- **Stale version pins in installation / docker / admin examples.** - **Stale version pins in installation / docker / admin examples.**
`getting-started/installation.mdx` and `self-hosting/docker.mdx` show `getting-started/installation.mdx` and `self-hosting/docker.mdx` show
`:1.2.0` as the pin example; `api/admin.mdx` returns `"version": `:1.2.0` as the pin example; `api/admin.mdx` returns `"version":
"1.2.1"` in its sample response. Bump all three to a current `1.4.x` "1.2.1"` in its sample response. Bump all three to a current `1.4.x`
reference on the next docs sweep. reference on the next docs sweep.
- **`architecture/database.mdx` model count + measurement-type list.** - **`architecture/database.mdx` model count + measurement-type list.**
Page claims **22 models**; current schema has **26**. Measurement Page claims **22 models**; current schema has **26**. Measurement
+1 -1
View File
@@ -33,7 +33,7 @@ Items deferred from the W5 reconcile pass. Carried into v1.4.23 +.
- **Design M6** (`dashboard-layout-section.tsx:204-241`): comparison-toggle has no live-preview affordance. Add a tiny `<TrendCard>` preview swatch under the picker. - **Design M6** (`dashboard-layout-section.tsx:204-241`): comparison-toggle has no live-preview affordance. Add a tiny `<TrendCard>` preview swatch under the picker.
- **Design M7** (`src/app/targets/page.tsx:286-301`): SVG sparkline `aria-hidden` skips the only "things changing" signal for SR users. Add `aria-label` to the `<TrendIcon>` parent OR move the sentiment into the delta caption prose. - **Design M7** (`src/app/targets/page.tsx:286-301`): SVG sparkline `aria-hidden` skips the only "things changing" signal for SR users. Add `aria-label` to the `<TrendIcon>` parent OR move the sentiment into the delta caption prose.
- **Design L1** (i18n voice asymmetry on `coach.evidenceLabel`): EN ("What I'm looking at") declarative; DE ("Worauf bezieht sich das?") curious. Decide on a unified voice. - **Design L1** (i18n voice asymmetry on `coach.evidenceLabel`): EN ("What I'm looking at") declarative; DE ("Worauf bezieht sich das?") curious. Decide on a unified voice.
- **Design L2** (`coach-panel/message-thread.tsx:286-304`): React `key={\`\${kv.label}-\${idx}\`}` allows duplicate-label collisions. Use a hash of `\${label}-\${window}-\${value}`. - **Design L2** (`coach-panel/message-thread.tsx:286-304`): React `key={\`\${kv.label}-\${idx}\`}`allows duplicate-label collisions. Use a hash of`\${label}-\${window}-\${value}`.
- **Design L3** (`targets/page.tsx:288`): sparkline `preserveAspectRatio="none"` distorts y vs x. Either keep + accept, or fix Y-range to the target range. - **Design L3** (`targets/page.tsx:288`): sparkline `preserveAspectRatio="none"` distorts y vs x. Either keep + accept, or fix Y-range to the target range.
- **Design L4** (`scroll-mt-28` overshoot): tuned to `scroll-mt-16` in W5 reconcile (Design-H1 commit). — APPLIED. - **Design L4** (`scroll-mt-28` overshoot): tuned to `scroll-mt-16` in W5 reconcile (Design-H1 commit). — APPLIED.
- **Design L5** (api-tokens table date wrap): cells wrap to two lines on 1024 px. Either short-format `formatDate` + tooltip, or push `<col>` width to 14 %. - **Design L5** (api-tokens table date wrap): cells wrap to two lines on 1024 px. Either short-format `formatDate` + tooltip, or push `<col>` width to 14 %.
+14 -13
View File
@@ -339,46 +339,47 @@ render` block needs a one-line CLAUDE.md-rule comment.
Filed against `.planning/phase-D-v1419-product-lead-review.md`. Most Filed against `.planning/phase-D-v1419-product-lead-review.md`. Most
of the strategic content there is the v1.4.20 plan (Insights redesign of the strategic content there is the v1.4.20 plan (Insights redesign
+ AI Coach) which already sits in the handoff at
`~/Downloads/design_handoff_insights_redesign`. The items below are
v1.5+ scope and frame how v1.4.20's surfaces should grow.
- **Conversation persistence eviction** — once Coach storage ships, - AI Coach) which already sits in the handoff at
`~/Downloads/design_handoff_insights_redesign`. The items below are
v1.5+ scope and frame how v1.4.20's surfaces should grow.
* **Conversation persistence eviction** — once Coach storage ships,
auto-archive conversations after 90 days, hard-delete after 365. auto-archive conversations after 90 days, hard-delete after 365.
Quoted ~500 KB/user/month at 20 turns × 50 conversations. Quoted ~500 KB/user/month at 20 turns × 50 conversations.
- **Correlation FDR-control** — v1.4.20's scope is 3 hypotheses, no * **Correlation FDR-control** — v1.4.20's scope is 3 hypotheses, no
multiple-comparison correction needed. v1.5's auto-discovery multiple-comparison correction needed. v1.5's auto-discovery
surface needs Benjamini–Hochberg or similar before shipping. surface needs Benjamini–Hochberg or similar before shipping.
- **Apple Health absence in v1.4.20** — the redesign reserves three * **Apple Health absence in v1.4.20** — the redesign reserves three
micro-stat tiles. Two slots filled today (Withings pulse, mood); micro-stat tiles. Two slots filled today (Withings pulse, mood);
the third reads "Apple Health: coming with iOS app" until v1.5. the third reads "Apple Health: coming with iOS app" until v1.5.
Don't embarrass; label honestly. Don't embarrass; label honestly.
- **Streaming UI on slow networks** — Coach SSE needs explicit * **Streaming UI on slow networks** — Coach SSE needs explicit
Slow-3G testing before shipping; skeleton-shimmer / progressive Slow-3G testing before shipping; skeleton-shimmer / progressive
render are easy to get wrong at 30 KB/s. render are easy to get wrong at 30 KB/s.
- **Prompt-injection refusal in Coach** — open-ended user input box; * **Prompt-injection refusal in Coach** — open-ended user input box;
reuse v1.4.15 C1 scope-hardened refusal patterns and add reuse v1.4.15 C1 scope-hardened refusal patterns and add
Coach-specific prompt-test assertions. Every user message gets Coach-specific prompt-test assertions. Every user message gets
`redactSecrets()` before landing in system context. `redactSecrets()` before landing in system context.
- **`safeParse()` legacy-payload pattern** — Coach payload + Daily * **`safeParse()` legacy-payload pattern** — Coach payload + Daily
Briefing payload + Health Score payload each meet a strict zod Briefing payload + Health Score payload each meet a strict zod
schema and a cached blob. Each needs a legacy-payload-CTA path, schema and a cached blob. Each needs a legacy-payload-CTA path,
not a crash. Audit every new `safeParse()` call pre-tag (v1.4.17 not a crash. Audit every new `safeParse()` call pre-tag (v1.4.17
hotfix lesson). hotfix lesson).
- **Cross-user feedback aggregation prompt-tuning** — v1.4.16 B5e * **Cross-user feedback aggregation prompt-tuning** — v1.4.16 B5e
set up storage; the loop is still open. v1.4.20 wires it (Coach set up storage; the loop is still open. v1.4.20 wires it (Coach
conversations + recommendation thumbs feed the same aggregator; conversations + recommendation thumbs feed the same aggregator;
helpful-rate < 50 % per `(severity × confidence_band)` bucket helpful-rate < 50 % per `(severity × confidence_band)` bucket
appends OMIT/REPHRASE rules to the next PROMPT_VERSION). appends OMIT/REPHRASE rules to the next PROMPT_VERSION).
- **Coolify image-digest auto-deploy** — deferred since v1.4.16; 5- * **Coolify image-digest auto-deploy** — deferred since v1.4.16; 5-
min Marc-side UI toggle. v1.4.20 is the right release to flip so min Marc-side UI toggle. v1.4.20 is the right release to flip so
every AI Coach iteration cycle benefits from no-rebuild every AI Coach iteration cycle benefits from no-rebuild
docs/planning commits. docs/planning commits.
- **Native ARM runner matrix for `docker-publish`** — v1.4.16 * **Native ARM runner matrix for `docker-publish`** — v1.4.16
dropped arm64 due to qemu SIGILL; native `ubuntu-24.04-arm` dropped arm64 due to qemu SIGILL; native `ubuntu-24.04-arm`
re-adds it. Pairs with v1.5 iOS app on Apple Silicon dev re-adds it. Pairs with v1.5 iOS app on Apple Silicon dev
machines. machines.
- **Worktree mandate for parallel agents** — process debt; the * **Worktree mandate for parallel agents** — process debt; the
shared-cwd race continues to bundle stray files into unrelated shared-cwd race continues to bundle stray files into unrelated
commits (A4, A5, A6 in v1.4.19). v1.4.20 should require one commits (A4, A5, A6 in v1.4.19). v1.4.20 should require one
worktree per parallel agent. worktree per parallel agent.
+9 -9
View File
@@ -315,7 +315,7 @@ Coach extension for the new measurement types
`dailyBriefing` / `trendAnnotations` / `weeklyReport` / `dailyBriefing` / `trendAnnotations` / `weeklyReport` /
`storyboardAnnotations` / `healthScore` blocks; legacy v1.4.19 `storyboardAnnotations` / `healthScore` blocks; legacy v1.4.19
cached payloads still parse because every new field is nullable cached payloads still parse because every new field is nullable
+ optional. - optional.
- **Branch + release model.** `CONTRIBUTING.md` documents a - **Branch + release model.** `CONTRIBUTING.md` documents a
long-lived `develop` branch as the daily target and `main` as long-lived `develop` branch as the daily target and `main` as
release-only. End users follow `main` / latest tag; contributors release-only. End users follow `main` / latest tag; contributors
@@ -849,11 +849,11 @@ Coach extension for the new measurement types
permanent reject codes, the channel disables itself and writes an permanent reject codes, the channel disables itself and writes an
audit-log entry. Status + re-enable button live in Settings → audit-log entry. Status + re-enable button live in Settings →
Notifications. Notifications.
- reauth` und der Settings-Eintrag bittet um Neu-Verbindung — statt jeden - reauth`und der Settings-Eintrag bittet um Neu-Verbindung — statt jeden
Sync-Tick weiter gegen den Provider zu hämmern. _Refresh-token failures Sync-Tick weiter gegen den Provider zu hämmern. _Refresh-token failures
flip the integration to “needs reauth”. When the Withings or moodLog flip the integration to “needs reauth”. When the Withings or moodLog
refresh-token exchange fails (typically after 90 days without re-auth), refresh-token exchange fails (typically after 90 days without re-auth),
the integration switches to `error_reauth` and the Settings entry asks the integration switches to`error_reauth` and the Settings entry asks
for re-connect — instead of hammering the upstream every sync tick. for re-connect — instead of hammering the upstream every sync tick.
- Mobile chart containers no longer eat vertical scroll. On touch devices - Mobile chart containers no longer eat vertical scroll. On touch devices
the Recharts wrappers swallowed vertical pans; on slower devices that the Recharts wrappers swallowed vertical pans; on slower devices that
@@ -879,7 +879,7 @@ Coach extension for the new measurement types
— including start markers, denial reasons, and failures — each write an — including start markers, denial reasons, and failures — each write an
audit-log entry with actor and snapshot ID. Restore is additionally audit-log entry with actor and snapshot ID. Restore is additionally
protected by five independent gates (cookie-only admin auth, `confirm: protected by five independent gates (cookie-only admin auth, `confirm:
"RESTORE"` body, typed UI confirmation, idempotency-key wrap, "RESTORE"` body, typed UI confirmation, idempotency-key wrap,
pre-transaction enum validation). pre-transaction enum validation).
## [1.4.14] — 2026-05-09 ## [1.4.14] — 2026-05-09
@@ -905,8 +905,8 @@ Coach extension for the new measurement types
button behind a confirmation dialog clears the stored OpenAI or local button behind a confirmation dialog clears the stored OpenAI or local
provider key without touching anything else. provider key without touching anything else.
- MODEL`-Env-Var** als Operator-Override für das Codex- Modell-Slug, damit - MODEL`-Env-Var** als Operator-Override für das Codex- Modell-Slug, damit
alternative Modelle ohne Rebuild getestet werden können (z. B. wenn dein alternative Modelle ohne Rebuild getestet werden können (z. B. wenn dein
ChatGPT-Plan ein anderes Default-Modell bevorzugt). _New `CODEX_MODEL` ChatGPT-Plan ein anderes Default-Modell bevorzugt). _New `CODEX_MODEL`
env var lets operators override the Codex model slug without a rebuild — env var lets operators override the Codex model slug without a rebuild —
useful for testing alternate slugs against different ChatGPT plan tiers. useful for testing alternate slugs against different ChatGPT plan tiers.
+10 -12
View File
@@ -1,4 +1,3 @@
# Contributor Covenant Code of Conduct # Contributor Covenant Code of Conduct
## Our Pledge ## Our Pledge
@@ -11,19 +10,19 @@ We pledge to act and interact in ways that contribute to an open, welcoming, div
Examples of behavior that contributes to a positive environment for our community include: Examples of behavior that contributes to a positive environment for our community include:
* Demonstrating empathy and kindness toward other people - Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences - Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback - Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience - Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
* Focusing on what is best not just for us as individuals, but for the overall community - Focusing on what is best not just for us as individuals, but for the overall community
Examples of unacceptable behavior include: Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or advances of any kind - The use of sexualized language or imagery, and sexual attention or advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks - Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment - Public or private harassment
* Publishing others' private information, such as a physical or email address, without their explicit permission - Publishing others' private information, such as a physical or email address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a professional setting - Other conduct which could reasonably be considered inappropriate in a professional setting
## Enforcement Responsibilities ## Enforcement Responsibilities
@@ -82,4 +81,3 @@ For answers to common questions about this code of conduct, see the FAQ at [http
[Mozilla CoC]: https://github.com/mozilla/diversity [Mozilla CoC]: https://github.com/mozilla/diversity
[FAQ]: https://www.contributor-covenant.org/faq [FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations [translations]: https://www.contributor-covenant.org/translations
+20 -10
View File
@@ -210,7 +210,8 @@ components:
- error - error
type: object type: object
DeviceRegisterRequest: DeviceRegisterRequest:
description: Native device registration. Re-registering an APNs token belonging to another user returns 409 description:
Native device registration. Re-registering an APNs token belonging to another user returns 409
(cross-user-hijack guard). (cross-user-hijack guard).
properties: properties:
apnsEnvironment: apnsEnvironment:
@@ -353,7 +354,8 @@ components:
type: object type: object
InsightsComprehensiveResponse: InsightsComprehensiveResponse:
additionalProperties: false additionalProperties: false
description: AI-generated insights bundle. Strict-schema validated server-side; Coach-routed when the insight surface description:
AI-generated insights bundle. Strict-schema validated server-side; Coach-routed when the insight surface
needs day-level grounding. needs day-level grounding.
properties: properties:
citations: citations:
@@ -707,7 +709,8 @@ openapi: 3.1.0
paths: paths:
/api/auth/login: /api/auth/login:
post: post:
description: "Browser callers receive a session cookie. Native callers (X-Client-Type: native or HealthLog-iOS UA description:
"Browser callers receive a session cookie. Native callers (X-Client-Type: native or HealthLog-iOS UA
prefix) additionally receive a paired access + refresh token." prefix) additionally receive a paired access + refresh token."
requestBody: requestBody:
content: content:
@@ -755,7 +758,8 @@ paths:
- Auth - Auth
/api/auth/me/coach-prefs: /api/auth/me/coach-prefs:
get: get:
description: Returns the persisted preferences with defaults filled in. Null persisted state resolves to the legacy description:
Returns the persisted preferences with defaults filled in. Null persisted state resolves to the legacy
v1.4.22 defaults. v1.4.22 defaults.
responses: responses:
"200": "200":
@@ -771,7 +775,8 @@ paths:
tags: tags:
- Auth - Auth
put: put:
description: Persists the supplied prefs. Body is validated against `CoachPrefs`; missing keys fall back to the description:
Persists the supplied prefs. Body is validated against `CoachPrefs`; missing keys fall back to the
documented defaults. documented defaults.
requestBody: requestBody:
content: content:
@@ -849,7 +854,8 @@ paths:
- Auth - Auth
/api/auth/refresh: /api/auth/refresh:
post: post:
description: Reuse of a consumed refresh token revokes every refresh token still active for the originating device description:
Reuse of a consumed refresh token revokes every refresh token still active for the originating device
(per-device blast radius from v1.4.23). Legacy tokens issued before v1.4.23 with a null deviceId fall back to (per-device blast radius from v1.4.23). Legacy tokens issued before v1.4.23 with a null deviceId fall back to
revoke-all-for-user. revoke-all-for-user.
requestBody: requestBody:
@@ -873,7 +879,8 @@ paths:
- Auth - Auth
/api/devices: /api/devices:
post: post:
description: Idempotent upsert by `token`. APNs token + environment are paired — supplying one without the other returns description:
Idempotent upsert by `token`. APNs token + environment are paired — supplying one without the other returns
422. Cross-user re-registration of either identifier returns 409. 422. Cross-user re-registration of either identifier returns 409.
requestBody: requestBody:
content: content:
@@ -902,7 +909,8 @@ paths:
- Devices - Devices
/api/insights/chat/messages/{id}/feedback: /api/insights/chat/messages/{id}/feedback:
post: post:
description: Persists a helpful/unhelpful rating for a single Coach reply. Reuses the v1.4.16 RecommendationFeedback description:
Persists a helpful/unhelpful rating for a single Coach reply. Reuses the v1.4.16 RecommendationFeedback
table via the polymorphic `targetType` column. The aggregator buckets ratings by (promptVersion, tone, table via the polymorphic `targetType` column. The aggregator buckets ratings by (promptVersion, tone,
verbosity). verbosity).
requestBody: requestBody:
@@ -938,7 +946,8 @@ paths:
- Insights - Insights
/api/insights/comprehensive: /api/insights/comprehensive:
get: get:
description: Full Insights surface — daily briefing, recommendations with rationale, optional weekly report + storyboard description:
Full Insights surface — daily briefing, recommendations with rationale, optional weekly report + storyboard
annotations. Strict-schema validated server-side. annotations. Strict-schema validated server-side.
responses: responses:
"200": "200":
@@ -1110,7 +1119,8 @@ paths:
- Measurements - Measurements
/api/measurements/batch: /api/measurements/batch:
post: post:
description: Up to 500 HealthKit entries per call. Idempotent via the `Idempotency-Key` header (replay window 24h). description:
Up to 500 HealthKit entries per call. Idempotent via the `Idempotency-Key` header (replay window 24h).
Per-entry status lets the iOS client advance its sync cursor accurately. Per-entry status lets the iOS client advance its sync cursor accurately.
requestBody: requestBody:
content: content:
+27 -27
View File
@@ -38,22 +38,22 @@ annotated tag trigger image publication. The model is documented in
## Production deploy ## Production deploy
| Field | Value | | Field | Value |
| ----- | ----- | | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| URL | `https://healthlog.bombeck.io` | | URL | `https://healthlog.bombeck.io` |
| `/api/version` | `1.4.20` (flipped at 2026-05-10T16:49:25Z) | | `/api/version` | `1.4.20` (flipped at 2026-05-10T16:49:25Z) |
| Image digest BEFORE | `sha256:b48f93874cdbcd6c2d729f1b8eeb63a6d1bbb90d56f629846ef6eab6cf272aa9` (v1.4.19) | | Image digest BEFORE | `sha256:b48f93874cdbcd6c2d729f1b8eeb63a6d1bbb90d56f629846ef6eab6cf272aa9` (v1.4.19) |
| Image digest AFTER | `sha256:b112a31947b91f2fdb4bb01b60c6b17a0b8ec128ae116d37e1094da5c3decaa6` (v1.4.20) | | Image digest AFTER | `sha256:b112a31947b91f2fdb4bb01b60c6b17a0b8ec128ae116d37e1094da5c3decaa6` (v1.4.20) |
| Release-prep commit (develop) | `aac8b5e chore(release): v1.4.20` | | Release-prep commit (develop) | `aac8b5e chore(release): v1.4.20` |
| Develop ↔ main sync commit | `dd2ef57 Merge main into develop — sync Dependabot dep bumps before v1.4.20 tag` | | Develop ↔ main sync commit | `dd2ef57 Merge main into develop — sync Dependabot dep bumps before v1.4.20 tag` |
| Release-merge commit (main) | `666f6ee Release v1.4.20` | | Release-merge commit (main) | `666f6ee Release v1.4.20` |
| Tag | `v1.4.20` (annotated) at `666f6ee` | | Tag | `v1.4.20` (annotated) at `666f6ee` |
| GHCR — tag run | `25633807964` success (4m02s) | | GHCR — tag run | `25633807964` success (4m02s) |
| Coolify auto-deploy | NO — webhook secret still missing on the deploy server (same fault mode as v1.4.19); host-side retag fallback used | | Coolify auto-deploy | NO — webhook secret still missing on the deploy server (same fault mode as v1.4.19); host-side retag fallback used |
| Smoke (curl, no session) | `/api/version` 200 · `/auth/login` 200 · all gated routes return 307 → `/auth/login` (expected proxy.ts behaviour without a session cookie) | | Smoke (curl, no session) | `/api/version` 200 · `/auth/login` 200 · all gated routes return 307 → `/auth/login` (expected proxy.ts behaviour without a session cookie) |
| GH release | https://github.com/MBombeck/HealthLog/releases/tag/v1.4.20 | | GH release | https://github.com/MBombeck/HealthLog/releases/tag/v1.4.20 |
| Docs site | `0b83430` on `healthlog-docs/main` (updates.mdx + scaling.mdx version pins; `ai-insights.mdx` v1.4.20 callout) | | Docs site | `0b83430` on `healthlog-docs/main` (updates.mdx + scaling.mdx version pins; `ai-insights.mdx` v1.4.20 callout) |
| Landing site | `43ce4c7` on `healthlog-landing/main` (`softwareVersion` JSON-LD bumped, three new featureList entries) | | Landing site | `43ce4c7` on `healthlog-landing/main` (`softwareVersion` JSON-LD bumped, three new featureList entries) |
The tag-build pipeline succeeded; the deploy webhook still isn't The tag-build pipeline succeeded; the deploy webhook still isn't
wired on apps-01, so v1.4.20 was promoted via the documented wired on apps-01, so v1.4.20 was promoted via the documented
@@ -84,17 +84,17 @@ cookie, every authenticated route correctly returns 307 →
`/auth/login`, so the smoke verifies the proxy gate is healthy `/auth/login`, so the smoke verifies the proxy gate is healthy
end-to-end: end-to-end:
| Path | Status (no session) | Notes | | Path | Status (no session) | Notes |
| ---- | ------------------- | ----- | | --------------------------- | ------------------- | ------------------------------------------------------ |
| `/api/version` | 200 | Returns `version: "1.4.20"` | | `/api/version` | 200 | Returns `version: "1.4.20"` |
| `/auth/login` | 200 | Public surface, renders normally | | `/auth/login` | 200 | Public surface, renders normally |
| `/` | 307 → `/auth/login` | Gated dashboard root | | `/` | 307 → `/auth/login` | Gated dashboard root |
| `/insights` | 307 → `/auth/login` | Redesigned Insights shell | | `/insights` | 307 → `/auth/login` | Redesigned Insights shell |
| `/insights/report/2026-W19` | 307 → `/auth/login` | New v1.4.20 weekly-report route | | `/insights/report/2026-W19` | 307 → `/auth/login` | New v1.4.20 weekly-report route |
| `/admin/api-tokens` | 307 → `/auth/login` | Admin section | | `/admin/api-tokens` | 307 → `/auth/login` | Admin section |
| `/settings/integrations` | 307 → `/auth/login` | Settings section | | `/settings/integrations` | 307 → `/auth/login` | Settings section |
| `/achievements` | 307 → `/auth/login` | Achievements page | | `/achievements` | 307 → `/auth/login` | Achievements page |
| `/dashboard` | 307 → `/auth/login` | Expected: root is the dashboard, no `/dashboard` route | | `/dashboard` | 307 → `/auth/login` | Expected: root is the dashboard, no `/dashboard` route |
The Coach drawer and Health Score panel can't be smoked without a The Coach drawer and Health Score panel can't be smoked without a
live session — neither carries a public surface. Confirmed live via live session — neither carries a public surface. Confirmed live via
+8 -8
View File
@@ -6,14 +6,14 @@ v1.4.21 ships as a same-day patch on top of v1.4.20 to fix a regression in the I
## Live state ## Live state
| Field | Value | | Field | Value |
|---|---| | ------------------ | ------------------------------------------------------------------------------------- |
| URL | `https://healthlog.bombeck.io` | | URL | `https://healthlog.bombeck.io` |
| `/api/version` | `1.4.21` | | `/api/version` | `1.4.21` |
| Image digest | `sha256:4e818d44702c3581a14d6480a953fd20d16cbbaf21c41e0c778c07340d3c4b1c` | | Image digest | `sha256:4e818d44702c3581a14d6480a953fd20d16cbbaf21c41e0c778c07340d3c4b1c` |
| Version transition | 2026-05-10T17:46:xx (host-side retag fallback) | | Version transition | 2026-05-10T17:46:xx (host-side retag fallback) |
| GH release | https://github.com/MBombeck/HealthLog/releases/tag/v1.4.21 | | GH release | https://github.com/MBombeck/HealthLog/releases/tag/v1.4.21 |
| Branch model | first patch through `develop` → `main` release-merge model (introduced in v1.4.20 F1) | | Branch model | first patch through `develop` → `main` release-merge model (introduced in v1.4.20 F1) |
## Smoke (no session) ## Smoke (no session)
@@ -60,22 +60,30 @@ function req(): NextRequest {
beforeEach(() => { beforeEach(() => {
vi.resetAllMocks(); vi.resetAllMocks();
vi.mocked(prisma.refreshToken.findMany).mockResolvedValue([]); vi.mocked(prisma.refreshToken.findMany).mockResolvedValue([]);
vi.mocked(prisma.refreshToken.updateMany).mockResolvedValue({ count: 0 } as never); vi.mocked(prisma.refreshToken.updateMany).mockResolvedValue({
vi.mocked(prisma.apiToken.updateMany).mockResolvedValue({ count: 0 } as never); count: 0,
} as never);
vi.mocked(prisma.apiToken.updateMany).mockResolvedValue({
count: 0,
} as never);
vi.mocked(prisma.$transaction).mockResolvedValue([] as never); vi.mocked(prisma.$transaction).mockResolvedValue([] as never);
}); });
describe("DELETE /api/auth/me/devices/[id]", () => { describe("DELETE /api/auth/me/devices/[id]", () => {
it("returns 401 when unauthenticated", async () => { it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null); vi.mocked(getSession).mockResolvedValue(null);
const res = await DELETE(req(), { params: Promise.resolve({ id: "dev-1" }) }); const res = await DELETE(req(), {
params: Promise.resolve({ id: "dev-1" }),
});
expect(res.status).toBe(401); expect(res.status).toBe(401);
}); });
it("returns 404 when the device doesn't exist", async () => { it("returns 404 when the device doesn't exist", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.device.findUnique).mockResolvedValue(null); vi.mocked(prisma.device.findUnique).mockResolvedValue(null);
const res = await DELETE(req(), { params: Promise.resolve({ id: "dev-x" }) }); const res = await DELETE(req(), {
params: Promise.resolve({ id: "dev-x" }),
});
expect(res.status).toBe(404); expect(res.status).toBe(404);
}); });
@@ -87,7 +95,9 @@ describe("DELETE /api/auth/me/devices/[id]", () => {
model: "iPhone", model: "iPhone",
bundleId: "io.healthlog.app", bundleId: "io.healthlog.app",
} as never); } as never);
const res = await DELETE(req(), { params: Promise.resolve({ id: "dev-x" }) }); const res = await DELETE(req(), {
params: Promise.resolve({ id: "dev-x" }),
});
expect(res.status).toBe(404); expect(res.status).toBe(404);
expect(vi.mocked(prisma.device.delete)).not.toHaveBeenCalled(); expect(vi.mocked(prisma.device.delete)).not.toHaveBeenCalled();
}); });
@@ -105,11 +115,17 @@ describe("DELETE /api/auth/me/devices/[id]", () => {
{ accessTokenHash: "hash:a2" }, { accessTokenHash: "hash:a2" },
{ accessTokenHash: null }, { accessTokenHash: null },
] as never); ] as never);
vi.mocked(prisma.refreshToken.updateMany).mockResolvedValue({ count: 3 } as never); vi.mocked(prisma.refreshToken.updateMany).mockResolvedValue({
vi.mocked(prisma.apiToken.updateMany).mockResolvedValue({ count: 2 } as never); count: 3,
} as never);
vi.mocked(prisma.apiToken.updateMany).mockResolvedValue({
count: 2,
} as never);
vi.mocked(prisma.device.delete).mockResolvedValue({ id: "dev-1" } as never); vi.mocked(prisma.device.delete).mockResolvedValue({ id: "dev-1" } as never);
const res = await DELETE(req(), { params: Promise.resolve({ id: "dev-1" }) }); const res = await DELETE(req(), {
params: Promise.resolve({ id: "dev-1" }),
});
expect(res.status).toBe(200); expect(res.status).toBe(200);
const body = await res.json(); const body = await res.json();
expect(body.data.revoked).toBe(true); expect(body.data.revoked).toBe(true);
@@ -120,7 +136,8 @@ describe("DELETE /api/auth/me/devices/[id]", () => {
}); });
// The refresh-token revoke filter MUST scope to the deviceId, not // The refresh-token revoke filter MUST scope to the deviceId, not
// every refresh row the user has. // every refresh row the user has.
const updateCall = vi.mocked(prisma.refreshToken.updateMany).mock.calls[0]?.[0]; const updateCall = vi.mocked(prisma.refreshToken.updateMany).mock
.calls[0]?.[0];
expect(updateCall?.where).toMatchObject({ expect(updateCall?.where).toMatchObject({
userId: "user-1", userId: "user-1",
deviceId: "dev-1", deviceId: "dev-1",
+5 -1
View File
@@ -55,7 +55,11 @@ export const DELETE = apiHandler(
}, },
}); });
annotate({ annotate({
action: { name: "auth.me.devices.revoke", entity_type: "device", entity_id: result.id }, action: {
name: "auth.me.devices.revoke",
entity_type: "device",
entity_id: result.id,
},
meta: { meta: {
refresh_tokens_revoked: result.refreshTokensRevoked, refresh_tokens_revoked: result.refreshTokensRevoked,
access_tokens_revoked: result.accessTokensRevoked, access_tokens_revoked: result.accessTokensRevoked,
@@ -60,15 +60,21 @@ function req(): NextRequest {
beforeEach(() => { beforeEach(() => {
vi.resetAllMocks(); vi.resetAllMocks();
vi.mocked(prisma.refreshToken.findMany).mockResolvedValue([]); vi.mocked(prisma.refreshToken.findMany).mockResolvedValue([]);
vi.mocked(prisma.refreshToken.updateMany).mockResolvedValue({ count: 0 } as never); vi.mocked(prisma.refreshToken.updateMany).mockResolvedValue({
vi.mocked(prisma.apiToken.updateMany).mockResolvedValue({ count: 0 } as never); count: 0,
} as never);
vi.mocked(prisma.apiToken.updateMany).mockResolvedValue({
count: 0,
} as never);
vi.mocked(prisma.$transaction).mockResolvedValue([] as never); vi.mocked(prisma.$transaction).mockResolvedValue([] as never);
}); });
describe("DELETE /api/devices/[id] (iOS token-rotation endpoint)", () => { describe("DELETE /api/devices/[id] (iOS token-rotation endpoint)", () => {
it("returns 401 when unauthenticated", async () => { it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null); vi.mocked(getSession).mockResolvedValue(null);
const res = await DELETE(req(), { params: Promise.resolve({ id: "dev-1" }) }); const res = await DELETE(req(), {
params: Promise.resolve({ id: "dev-1" }),
});
expect(res.status).toBe(401); expect(res.status).toBe(401);
}); });
@@ -80,7 +86,9 @@ describe("DELETE /api/devices/[id] (iOS token-rotation endpoint)", () => {
model: "iPhone", model: "iPhone",
bundleId: "io.healthlog.app", bundleId: "io.healthlog.app",
} as never); } as never);
const res = await DELETE(req(), { params: Promise.resolve({ id: "dev-x" }) }); const res = await DELETE(req(), {
params: Promise.resolve({ id: "dev-x" }),
});
expect(res.status).toBe(404); expect(res.status).toBe(404);
}); });
@@ -97,7 +105,9 @@ describe("DELETE /api/devices/[id] (iOS token-rotation endpoint)", () => {
] as never); ] as never);
vi.mocked(prisma.device.delete).mockResolvedValue({ id: "dev-1" } as never); vi.mocked(prisma.device.delete).mockResolvedValue({ id: "dev-1" } as never);
const res = await DELETE(req(), { params: Promise.resolve({ id: "dev-1" }) }); const res = await DELETE(req(), {
params: Promise.resolve({ id: "dev-1" }),
});
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(vi.mocked(prisma.device.delete)).toHaveBeenCalled(); expect(vi.mocked(prisma.device.delete)).toHaveBeenCalled();
}); });
+5 -1
View File
@@ -46,7 +46,11 @@ export const DELETE = apiHandler(
}, },
}); });
annotate({ annotate({
action: { name: "devices.revoke", entity_type: "device", entity_id: result.id }, action: {
name: "devices.revoke",
entity_type: "device",
entity_id: result.id,
},
meta: { meta: {
refresh_tokens_revoked: result.refreshTokensRevoked, refresh_tokens_revoked: result.refreshTokensRevoked,
access_tokens_revoked: result.accessTokensRevoked, access_tokens_revoked: result.accessTokensRevoked,
+1 -4
View File
@@ -114,10 +114,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
deviceId: existingApns.id, deviceId: existingApns.id,
}, },
}); });
return apiError( return apiError("APNs token already registered to another account", 409);
"APNs token already registered to another account",
409,
);
} }
} }
+9 -6
View File
@@ -25,7 +25,12 @@ import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context"; import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit"; import { auditLog } from "@/lib/auth/audit";
import { apiError, apiSuccess, getClientIp, safeJson } from "@/lib/api-response"; import {
apiError,
apiSuccess,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { withIdempotency } from "@/lib/idempotency"; import { withIdempotency } from "@/lib/idempotency";
import { mapAppleHealthEntry } from "@/lib/measurements/apple-health-mapping"; import { mapAppleHealthEntry } from "@/lib/measurements/apple-health-mapping";
import { validateMeasurementRange } from "@/lib/validations/measurement"; import { validateMeasurementRange } from "@/lib/validations/measurement";
@@ -84,11 +89,9 @@ async function postBatch(request: NextRequest): Promise<Response> {
Array.isArray((rawBody as { entries: unknown }).entries) && Array.isArray((rawBody as { entries: unknown }).entries) &&
(rawBody as { entries: unknown[] }).entries.length > MAX_BATCH_ENTRIES (rawBody as { entries: unknown[] }).entries.length > MAX_BATCH_ENTRIES
) { ) {
return apiError( return apiError(`Batch exceeds the ${MAX_BATCH_ENTRIES}-entry limit`, 422, {
`Batch exceeds the ${MAX_BATCH_ENTRIES}-entry limit`, errorCode: "coach.batch.too_large",
422, });
{ errorCode: "coach.batch.too_large" },
);
} }
const parsed = batchPayloadSchema.safeParse(rawBody); const parsed = batchPayloadSchema.safeParse(rawBody);
@@ -97,7 +97,10 @@ describe("<AdminShell>", () => {
// missing from the nav even though the slug + page + i18n shipped. // missing from the nav even though the slug + page + i18n shipped.
const navSlugs = new Set(ADMIN_SECTIONS.map((s) => s.slug)); const navSlugs = new Set(ADMIN_SECTIONS.map((s) => s.slug));
for (const slug of ADMIN_SECTION_SLUGS) { for (const slug of ADMIN_SECTION_SLUGS) {
expect(navSlugs.has(slug), `ADMIN_SECTIONS missing nav entry for ${slug}`).toBe(true); expect(
navSlugs.has(slug),
`ADMIN_SECTIONS missing nav entry for ${slug}`,
).toBe(true);
} }
}); });
@@ -124,7 +127,9 @@ describe("<AdminShell>", () => {
// filesystem read isn't accidentally noop'd by a future tree- // filesystem read isn't accidentally noop'd by a future tree-
// shaker pass. // shaker pass.
expect( expect(
statSync(join(process.cwd(), "src/app/admin/[section]/page.tsx")).isFile(), statSync(
join(process.cwd(), "src/app/admin/[section]/page.tsx"),
).isFile(),
).toBe(true); ).toBe(true);
}); });
@@ -109,7 +109,9 @@ export function nextResettableValue<T>(
previous: T, previous: T,
incoming: T, incoming: T,
): { reset: true; value: T } | { reset: false } { ): { reset: true; value: T } | { reset: false } {
return Object.is(previous, incoming) ? { reset: false } : { reset: true, value: incoming }; return Object.is(previous, incoming)
? { reset: false }
: { reset: true, value: incoming };
} }
export function CoachDrawer({ export function CoachDrawer({
@@ -130,7 +130,7 @@ export function CoachSettingsSheet({
<SheetContent <SheetContent
side="right" side="right"
data-slot="coach-settings-sheet" data-slot="coach-settings-sheet"
className="w-full p-0 sm:max-w-[420px] flex h-[100dvh] flex-col gap-0" className="flex h-[100dvh] w-full flex-col gap-0 p-0 sm:max-w-[420px]"
> >
<SheetHeader className="border-border/70 border-b p-4 pr-12"> <SheetHeader className="border-border/70 border-b p-4 pr-12">
<SheetTitle className="text-sm font-semibold"> <SheetTitle className="text-sm font-semibold">
@@ -171,10 +171,7 @@ export function CoachSettingsSheet({
<div className="flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto p-4"> <div className="flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto p-4">
{/* Tone */} {/* Tone */}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label <Label htmlFor="coach-prefs-tone" className="text-xs font-medium">
htmlFor="coach-prefs-tone"
className="text-xs font-medium"
>
{t("insights.coach.settingsToneLabel")} {t("insights.coach.settingsToneLabel")}
</Label> </Label>
<Select <Select
@@ -267,9 +264,7 @@ export function CoachSettingsSheet({
id={id} id={id}
data-slot={`coach-prefs-exclude-${metric}`} data-slot={`coach-prefs-exclude-${metric}`}
checked={checked} checked={checked}
onCheckedChange={(next) => onCheckedChange={(next) => toggleExclude(metric, next)}
toggleExclude(metric, next)
}
/> />
</li> </li>
); );
@@ -323,10 +318,7 @@ export function CoachSettingsSheet({
data-slot="coach-prefs-save" data-slot="coach-prefs-save"
> >
{save.isPending ? ( {save.isPending ? (
<Loader2 <Loader2 className="size-3.5 animate-spin" aria-hidden="true" />
className="size-3.5 animate-spin"
aria-hidden="true"
/>
) : null} ) : null}
{t("insights.coach.settingsSave")} {t("insights.coach.settingsSave")}
</Button> </Button>
@@ -444,10 +444,7 @@ function CoachMessageFeedback({ messageId }: CoachMessageFeedbackProps) {
} }
return ( return (
<div <div data-slot="coach-message-feedback" className="flex items-center gap-2">
data-slot="coach-message-feedback"
className="flex items-center gap-2"
>
<button <button
type="button" type="button"
data-slot="coach-message-feedback-helpful" data-slot="coach-message-feedback-helpful"
@@ -94,9 +94,7 @@ describe("MeasurementList — APPLE_HEALTH badge", () => {
// duplicate hand-entries on top of synced rows. Both branches must // duplicate hand-entries on top of synced rows. Both branches must
// carry the badge so the surface is honest at every viewport. // carry the badge so the surface is honest at every viewport.
const html = render("en"); const html = render("en");
const matches = html.match( const matches = html.match(/data-testid="measurement-source-badge"/g);
/data-testid="measurement-source-badge"/g,
);
expect(matches?.length ?? 0).toBe(2); expect(matches?.length ?? 0).toBe(2);
}); });
}); });
@@ -52,7 +52,9 @@ describe("getStrictInsightsSystemPrompt — EN", () => {
}); });
it("documents the additive sourceMetric tokens in the OUTPUT FORMAT", () => { it("documents the additive sourceMetric tokens in the OUTPUT FORMAT", () => {
expect(prompt).toMatch(/hrv \| sleep \| resting_hr \| steps \| active_energy/); expect(prompt).toMatch(
/hrv \| sleep \| resting_hr \| steps \| active_energy/,
);
}); });
}); });
@@ -70,6 +72,8 @@ describe("getStrictInsightsSystemPrompt — DE", () => {
}); });
it("documents the additive sourceMetric tokens in the German OUTPUT FORMAT", () => { it("documents the additive sourceMetric tokens in the German OUTPUT FORMAT", () => {
expect(prompt).toMatch(/hrv \| sleep \| resting_hr \| steps \| active_energy/); expect(prompt).toMatch(
/hrv \| sleep \| resting_hr \| steps \| active_energy/,
);
}); });
}); });
+54 -9
View File
@@ -523,15 +523,60 @@ export async function buildCoachSnapshot(
// Apple Health source maps to exactly one MeasurementType (BP is // Apple Health source maps to exactly one MeasurementType (BP is
// the only fan-out and lives in its own legacy block above). // the only fan-out and lives in its own legacy block above).
const appleHealthBlocks: AppleHealthBlock[] = [ const appleHealthBlocks: AppleHealthBlock[] = [
{ metric: "hrv", snapshotKey: "heartRateVariability", type: "HEART_RATE_VARIABILITY", enabled: sources.has("hrv") }, {
{ metric: "sleep", snapshotKey: "sleep", type: "SLEEP_DURATION", enabled: sources.has("sleep") }, metric: "hrv",
{ metric: "resting_hr", snapshotKey: "restingHeartRate", type: "RESTING_HEART_RATE", enabled: sources.has("resting_hr") }, snapshotKey: "heartRateVariability",
{ metric: "steps", snapshotKey: "steps", type: "ACTIVITY_STEPS", enabled: sources.has("steps") }, type: "HEART_RATE_VARIABILITY",
{ metric: "active_energy", snapshotKey: "activeEnergy", type: "ACTIVE_ENERGY_BURNED", enabled: sources.has("active_energy") }, enabled: sources.has("hrv"),
{ metric: "flights", snapshotKey: "flightsClimbed", type: "FLIGHTS_CLIMBED", enabled: sources.has("flights") }, },
{ metric: "distance", snapshotKey: "walkingRunningDistance", type: "WALKING_RUNNING_DISTANCE", enabled: sources.has("distance") }, {
{ metric: "vo2_max", snapshotKey: "vo2Max", type: "VO2_MAX", enabled: sources.has("vo2_max") }, metric: "sleep",
{ metric: "body_temp", snapshotKey: "bodyTemperature", type: "BODY_TEMPERATURE", enabled: sources.has("body_temp") }, snapshotKey: "sleep",
type: "SLEEP_DURATION",
enabled: sources.has("sleep"),
},
{
metric: "resting_hr",
snapshotKey: "restingHeartRate",
type: "RESTING_HEART_RATE",
enabled: sources.has("resting_hr"),
},
{
metric: "steps",
snapshotKey: "steps",
type: "ACTIVITY_STEPS",
enabled: sources.has("steps"),
},
{
metric: "active_energy",
snapshotKey: "activeEnergy",
type: "ACTIVE_ENERGY_BURNED",
enabled: sources.has("active_energy"),
},
{
metric: "flights",
snapshotKey: "flightsClimbed",
type: "FLIGHTS_CLIMBED",
enabled: sources.has("flights"),
},
{
metric: "distance",
snapshotKey: "walkingRunningDistance",
type: "WALKING_RUNNING_DISTANCE",
enabled: sources.has("distance"),
},
{
metric: "vo2_max",
snapshotKey: "vo2Max",
type: "VO2_MAX",
enabled: sources.has("vo2_max"),
},
{
metric: "body_temp",
snapshotKey: "bodyTemperature",
type: "BODY_TEMPERATURE",
enabled: sources.has("body_temp"),
},
]; ];
for (const block of appleHealthBlocks) { for (const block of appleHealthBlocks) {
if (!block.enabled) continue; if (!block.enabled) continue;
+3 -3
View File
@@ -142,8 +142,8 @@ describe("revokeDeviceCascade", () => {
new Error("postgres-blip"), new Error("postgres-blip"),
); );
await expect( await expect(revokeDeviceCascade("user-1", "dev-3")).rejects.toThrow(
revokeDeviceCascade("user-1", "dev-3"), "postgres-blip",
).rejects.toThrow("postgres-blip"); );
}); });
}); });
@@ -38,7 +38,8 @@ describe("APPLE_HEALTH_TYPE_MAP", () => {
}); });
it("converts oxygen saturation from 0..1 fraction to 0..100 percent", () => { it("converts oxygen saturation from 0..1 fraction to 0..100 percent", () => {
const mapping = APPLE_HEALTH_TYPE_MAP.HKQuantityTypeIdentifierOxygenSaturation; const mapping =
APPLE_HEALTH_TYPE_MAP.HKQuantityTypeIdentifierOxygenSaturation;
expect(mapping.convertToDbUnit(0.97)).toBeCloseTo(97); expect(mapping.convertToDbUnit(0.97)).toBeCloseTo(97);
}); });
+1 -4
View File
@@ -12,10 +12,7 @@
* DB unit, and the aggregation hint all live alongside the identifier * DB unit, and the aggregation hint all live alongside the identifier
* so future analytics work has a single discoverable surface. * so future analytics work has a single discoverable surface.
*/ */
import type { import type { MeasurementType, SleepStage } from "@/generated/prisma/client";
MeasurementType,
SleepStage,
} from "@/generated/prisma/client";
/** /**
* How a batch of HealthKit samples for the same identifier should be * How a batch of HealthKit samples for the same identifier should be
@@ -74,8 +74,7 @@ const VALID_ENV = {
APNS_KEY_ID: "ABCDE12345", APNS_KEY_ID: "ABCDE12345",
APNS_TEAM_ID: "TEAM123456", APNS_TEAM_ID: "TEAM123456",
APNS_BUNDLE_ID: "test.healthlog.ios", APNS_BUNDLE_ID: "test.healthlog.ios",
APNS_KEY: APNS_KEY: "-----BEGIN PRIVATE KEY-----\\nMOCKKEY\\n-----END PRIVATE KEY-----",
"-----BEGIN PRIVATE KEY-----\\nMOCKKEY\\n-----END PRIVATE KEY-----",
}; };
function setEnv(over: Record<string, string | undefined> = {}): void { function setEnv(over: Record<string, string | undefined> = {}): void {
@@ -269,7 +268,6 @@ describe("sendApnsPush — failure classification", () => {
expect(r.reason).toBe("apns_network_error"); expect(r.reason).toBe("apns_network_error");
expect(r.shouldDisable).toBeFalsy(); expect(r.shouldDisable).toBeFalsy();
}); });
}); });
describe("sendApnsPush — config disabled paths", () => { describe("sendApnsPush — config disabled paths", () => {
@@ -325,7 +323,11 @@ describe("sendViaApns — dispatcher fan-out", () => {
.mockResolvedValueOnce({ .mockResolvedValueOnce({
sent: [], sent: [],
failed: [ failed: [
{ device: "tok-b", status: 410, response: { reason: "Unregistered" } }, {
device: "tok-b",
status: 410,
response: { reason: "Unregistered" },
},
], ],
}); });
const r = await sendViaApns("u-1", { const r = await sendViaApns("u-1", {
@@ -349,13 +351,21 @@ describe("sendViaApns — dispatcher fan-out", () => {
.mockResolvedValueOnce({ .mockResolvedValueOnce({
sent: [], sent: [],
failed: [ failed: [
{ device: "tok-a", status: 410, response: { reason: "Unregistered" } }, {
device: "tok-a",
status: 410,
response: { reason: "Unregistered" },
},
], ],
}) })
.mockResolvedValueOnce({ .mockResolvedValueOnce({
sent: [], sent: [],
failed: [ failed: [
{ device: "tok-b", status: 400, response: { reason: "BadDeviceToken" } }, {
device: "tok-b",
status: 400,
response: { reason: "BadDeviceToken" },
},
], ],
}); });
const r = await sendViaApns("u-1", { const r = await sendViaApns("u-1", {
+1 -3
View File
@@ -126,9 +126,7 @@ export function loadApnsConfig(): ApnsConfig | null {
// configuring and stopped — surface that as a warning so the // configuring and stopped — surface that as a warning so the
// half-finished state is visible in logs. // half-finished state is visible in logs.
const anySet = Boolean(keyId || teamId || bundleId || inlineKey || keyFile); const anySet = Boolean(keyId || teamId || bundleId || inlineKey || keyFile);
const allSet = Boolean( const allSet = Boolean(keyId && teamId && bundleId && (inlineKey || keyFile));
keyId && teamId && bundleId && (inlineKey || keyFile),
);
if (!anySet) { if (!anySet) {
cachedConfig = null; cachedConfig = null;
+9 -8
View File
@@ -58,9 +58,7 @@ function dataEnvelope<T extends z.ZodType>(payload: T, id: string) {
.object({ .object({
data: payload, data: payload,
error: z.null(), error: z.null(),
meta: z meta: z.object({ requestId: z.string().optional() }).optional(),
.object({ requestId: z.string().optional() })
.optional(),
}) })
.meta({ id }); .meta({ id });
} }
@@ -118,7 +116,10 @@ const coachMessageFeedbackBody = z
const passkeyLoginVerifyRequest = z const passkeyLoginVerifyRequest = z
.object({ .object({
challengeId: z.string().min(1).describe("Server-issued WebAuthn challenge id."), challengeId: z
.string()
.min(1)
.describe("Server-issued WebAuthn challenge id."),
credential: z credential: z
.object({ .object({
id: z.string(), id: z.string(),
@@ -157,9 +158,7 @@ const refreshRequest = z
const accessRefreshBundle = z const accessRefreshBundle = z
.object({ .object({
user: z user: z.object({ id: z.string(), username: z.string() }).optional(),
.object({ id: z.string(), username: z.string() })
.optional(),
token: z.string().describe("Access token (`hlk_<64hex>`)."), token: z.string().describe("Access token (`hlk_<64hex>`)."),
tokenExpiresAt: z.iso.datetime({ offset: true }), tokenExpiresAt: z.iso.datetime({ offset: true }),
refreshToken: z refreshToken: z
@@ -216,7 +215,9 @@ const batchEntrySchema = z
.string() .string()
.min(1) .min(1)
.max(120) .max(120)
.describe("HealthKit identifier (e.g. `HKQuantityTypeIdentifierBodyMass`)."), .describe(
"HealthKit identifier (e.g. `HKQuantityTypeIdentifierBodyMass`).",
),
value: z.number().finite(), value: z.number().finite(),
unit: z.string().min(1).max(60), unit: z.string().min(1).max(60),
startDate: z.iso.datetime({ offset: true }), startDate: z.iso.datetime({ offset: true }),
+6 -9
View File
@@ -150,9 +150,8 @@ describe("APNs dispatcher integration", () => {
failed: [], failed: [],
}); });
const { dispatchNotification } = await import( const { dispatchNotification } =
"@/lib/notifications/dispatcher" await import("@/lib/notifications/dispatcher");
);
await dispatchNotification({ await dispatchNotification({
userId: TEST_USER_ID, userId: TEST_USER_ID,
eventType: "MEDICATION_REMINDER", eventType: "MEDICATION_REMINDER",
@@ -191,9 +190,8 @@ describe("APNs dispatcher integration", () => {
], ],
}); });
const { dispatchNotification } = await import( const { dispatchNotification } =
"@/lib/notifications/dispatcher" await import("@/lib/notifications/dispatcher");
);
await dispatchNotification({ await dispatchNotification({
userId: TEST_USER_ID, userId: TEST_USER_ID,
eventType: "SYSTEM_ALERT", eventType: "SYSTEM_ALERT",
@@ -245,9 +243,8 @@ describe("APNs dispatcher integration", () => {
telegramSendMock.mockResolvedValueOnce({ ok: true, messageId: 42 }); telegramSendMock.mockResolvedValueOnce({ ok: true, messageId: 42 });
const { dispatchNotification } = await import( const { dispatchNotification } =
"@/lib/notifications/dispatcher" await import("@/lib/notifications/dispatcher");
);
await dispatchNotification({ await dispatchNotification({
userId: TEST_USER_ID, userId: TEST_USER_ID,
eventType: "MEDICATION_REMINDER", eventType: "MEDICATION_REMINDER",
+12 -4
View File
@@ -89,7 +89,9 @@ describe("POST /api/devices", () => {
apnsToken: "abcdef0123456789", apnsToken: "abcdef0123456789",
apnsEnvironment: "sandbox", apnsEnvironment: "sandbox",
}); });
const res = await (POST as (r: import("next/server").NextRequest) => Promise<Response>)(req); const res = await (
POST as (r: import("next/server").NextRequest) => Promise<Response>
)(req);
expect(res.status).toBe(201); expect(res.status).toBe(201);
const channel = await getPrismaClient().notificationChannel.findFirst({ const channel = await getPrismaClient().notificationChannel.findFirst({
@@ -107,7 +109,9 @@ describe("POST /api/devices", () => {
token: "legacy-token-2", token: "legacy-token-2",
bundleId: "io.healthlog.ios", bundleId: "io.healthlog.ios",
}); });
const res = await (POST as (r: import("next/server").NextRequest) => Promise<Response>)(req); const res = await (
POST as (r: import("next/server").NextRequest) => Promise<Response>
)(req);
expect(res.status).toBe(201); expect(res.status).toBe(201);
const channel = await getPrismaClient().notificationChannel.findFirst({ const channel = await getPrismaClient().notificationChannel.findFirst({
@@ -120,7 +124,9 @@ describe("POST /api/devices", () => {
const user = await seedSession("device-apns-idempotent"); const user = await seedSession("device-apns-idempotent");
const { POST } = await import("@/app/api/devices/route"); const { POST } = await import("@/app/api/devices/route");
const first = await (POST as (r: import("next/server").NextRequest) => Promise<Response>)( const first = await (
POST as (r: import("next/server").NextRequest) => Promise<Response>
)(
buildRequest({ buildRequest({
token: "legacy-token-3", token: "legacy-token-3",
bundleId: "io.healthlog.ios", bundleId: "io.healthlog.ios",
@@ -130,7 +136,9 @@ describe("POST /api/devices", () => {
); );
expect(first.status).toBe(201); expect(first.status).toBe(201);
const second = await (POST as (r: import("next/server").NextRequest) => Promise<Response>)( const second = await (
POST as (r: import("next/server").NextRequest) => Promise<Response>
)(
buildRequest({ buildRequest({
token: "legacy-token-3", token: "legacy-token-3",
bundleId: "io.healthlog.ios", bundleId: "io.healthlog.ios",