docs(planning): A1 report + STATE update for BD-Zielbereich fix

Live-DB audit confirmed the headline was a literal copy of 30T
(`bpInTargetPct = windows.last30Days?.pct`); fix added an `allTime`
window so the three numbers compute independently. Marc's prod data
verifies the asymmetry: 7d=50%, 30d=50%, all-time≈11%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 13:02:39 +02:00
1 parent a8562727a7
commit dcef89c59e
2 files changed
+102 -73

No files matched your search

+28 -17
View File
@@ -18,13 +18,24 @@ Last update: 2026-05-10T12:48+02:00
## Wave A — Bug fixes + polish (parallel buckets)
### A1 — BD-Zielbereich constant 50% (4th attempt)
### A1 — BD-Zielbereich constant 50% (4th attempt) ✅
- [ ] Live-DB audit of Marc's BP measurements (he granted access)
- [ ] Identify why all three windows (7T, 30T, total) show exactly 50%
— likely calculation bug, NOT predicate
- [ ] Root-cause fix + integration test that reproduces with Marc's
exact data fixture
- [x] Live-DB audit of Marc's BP measurements (apps-01,
`db-pg8wggwogo8c4gc4ks0kk4ss-105148113251`): 572 paired
readings since 2022, last-30d = 50 %, last-7d = 50 %, all-time
≈ 10.8 %. Confirmed the headline 50 % cannot be the all-time
value; it must be a copy of `30T`.
- [x] Root cause: `analytics/route.ts` set
`bpInTargetPct = windows.last30Days?.pct` — literal copy of the
`30T` sub-value. `computeBpInTargetWindows` returned only 7d+30d
so the headline could never legitimately differ.
- [x] Fix: helper returns a third `allTime` window; route fetches all
paired BP rows and routes the headline through `allTime`.
- [x] TDD: 3 failing unit cases + 1 integration case red→green.
- [x] `pnpm test`: 1640 / 1645 (5 pre-existing A3 failures unchanged).
- [x] `pnpm test:integration`: 67 / 67.
- [x] `pnpm typecheck`: clean. `pnpm lint`: 12 baseline 0 new.
- [x] Commit `a856272` pushed to origin/main first attempt.
- Detailed report: `.planning/phase-A1-report.md`
### A2 — Charts mobile audit (axis-label overflow + X-axis density consistency)
@@ -152,18 +163,18 @@ Last update: 2026-05-10T12:48+02:00
AI-prompt rework, A5 Settings/Integrations status-UI consolidation,
A6 Settings mobile audit, A7 Admin polish, A8 quality-of-life
write-only audit; Wave B applies A8 findings; Wave D multi-agent QA
+ Product-Lead briefed for v1.4.20 Insights redesign + AI Coach;
Phase E release). Previous v1.4.18 / v1.4.17 / v1.4.16 entries
archived above. Tracked tree clean on entry; four untracked stale
dotted-segment route directories
(`src/app/api/export/{full-backup.json,measurements.csv,
- Product-Lead briefed for v1.4.20 Insights redesign + AI Coach;
Phase E release). Previous v1.4.18 / v1.4.17 / v1.4.16 entries
archived above. Tracked tree clean on entry; four untracked stale
dotted-segment route directories
(`src/app/api/export/{full-backup.json,measurements.csv,
medications.csv,mood.csv}/`) left in place — same call as v1.4.16 /
v1.4.18 Phase 0, they belong to previous milestones. Phase 0 commit
contains only `.planning/STATE.md`, `.planning/ROADMAP.md`,
`.planning/phase-0-report.md`. v1.4.20 reserved for Insights
redesign with AI Coach (handoff at
`~/Downloads/design_handoff_insights_redesign`); v1.5 reserved for
iOS app + Apple Health.
v1.4.18 Phase 0, they belong to previous milestones. Phase 0 commit
contains only `.planning/STATE.md`, `.planning/ROADMAP.md`,
`.planning/phase-0-report.md`. v1.4.20 reserved for Insights
redesign with AI Coach (handoff at
`~/Downloads/design_handoff_insights_redesign`); v1.5 reserved for
iOS app + Apple Health.
- 2026-05-10T12:59+02:00 — Wave A / A4 complete. Removed the
default-positivity opener about data quality from the AI insights
+74 -56
View File
@@ -1,76 +1,94 @@
# Phase A1 — BD-Zielbereich tile 7T / 30T sub-values
# Phase A1 — BD-Zielbereich constant 50% (4th attempt)
Marathon: v1.4.18 Wave-A bucket A1 (parallel with A2 api-tokens, A3
chart revert, B1 achievements)
Started: 2026-05-09T~10:08+02:00
Finished: 2026-05-09T~10:15+02:00
Marathon: v1.4.19 Wave-A bucket A1
Started: 2026-05-10T12:55+02:00
Finished: 2026-05-10T13:05+02:00
## Symptom
Marc reported the dashboard "BD im Zielbereich" tile rendered the
30-day headline correctly (**50,0 %**, the v1.4.16 A2 ceiling-semantics
fix) but the row underneath the headline still showed **`7T: —`** and
**`30T: —`** even though Marc clearly has paired BP readings in both
windows.
Marc reported the dashboard BD-Zielbereich tile pinned at **EXACTLY 50 %
on 7T, 30T, AND the headline (total)** for his real production data —
the fourth attempt at this surface (v1.4.15 → v1.4.16 → v1.4.18 →
v1.4.19). The earlier passes legitimately landed: v1.4.15 fixed the
denominator (paired readings, not `sysData.length`), v1.4.16 fixed the
predicate (one-sided ceiling with hypotension floor instead of narrow
band), v1.4.18 wired the `avg7` / `avg30` sub-values from a windowed
helper. None of those addressed why the headline kept matching `30T`.
## Live-DB audit (Marc's prod data on `apps-01`)
Container `db-pg8wggwogo8c4gc4ks0kk4ss-105148113251`, user
`cmlupy4tn000001rpzx1pxvz7`, DOB 1985-07-09 → under-65 target band
(sysHigh 129 / diaHigh 79 + clinical floor 90/50).
Closest-pair SQL aggregate over the canonical predicate:
| Scope | Pairs | In target | % |
| -------- | ----- | --------- | ------ |
| 7d | 2 | 1 | 50.0 % |
| 30d | 10 | 5 | 50.0 % |
| All time | 572 | 62 | 10.8 % |
The two recent 50 %s are a **real coincidence** (Marc's recent BP control
genuinely sits half above the diastolic ceiling). The headline cannot
legitimately be 50 % — it must be ~11 %.
## Root cause
`/api/analytics` (`src/app/api/analytics/route.ts`) only computed and
returned a single `bpInTargetPct` field — the share over the trailing
30 days. The dashboard tile (`src/app/page.tsx`) explicitly passed
`avg7={null}` and `avg30={null}` to the `TrendCard` component, which
correctly renders "—" as the friendly fallback when those fields are
null. So the sub-values were never wired to data, regardless of how
many readings the user had — pure unfinished feature, not a calculation
bug.
`src/app/api/analytics/route.ts` set
`bpInTargetPct = windows.last30Days?.pct`. The headline was a **literal
copy of the `30T` sub-value** — `bpInTargetPct === bpInTargetPct30d` by
construction. `computeBpInTargetWindows` only returned 7d + 30d windows,
so even with a different routing the headline could never differ from
one of the sub-values.
Confirmed against Marc's prod data on apps-01: user
`cmlupy4tn000001rpzx1pxvz7` has 2 paired sys+dia in last 7 days and
10 in last 30 days. Both windows have data; nothing should render "—".
The brief's hypothesis-1 was inverted in direction but otherwise
correct: not the 7T/30T branches reusing the all-time, but the
all-time headline reusing the 30-day. Hypothesis-2/3/4/5 (filter
no-op, rendering snap, boundary aliasing, cache hit) all ruled out
by the live-DB SQL aggregate.
## Fix
Added `computeBpInTargetWindows()` next to the existing
`computeBpInTargetPct()` helper in
`src/lib/analytics/bp-in-target.ts`. Same predicate +
`isBpReadingInTarget()` ceiling semantics from v1.4.16; the new
function filters the input series by `measuredAt >= now − Nd` and
delegates to `computeBpInTargetPct()` for each window. Returns
`{ last7Days, last30Days }`, both nullable so the tile renders "—" only
when a window genuinely has no paired readings.
1. `computeBpInTargetWindows` now returns a third `allTime` window
computed against the entire input series (no time filter).
2. Analytics route fetches all paired BP rows (drops the 30-day
`gte` filter) and routes the headline through `windows.allTime?.pct`.
Re-pairs once per request (`computeBpInTargetPct` runs three times
internally with progressively smaller filtered slices, but reads
the same input arrays).
3. 7T / 30T sub-values unchanged — same windowed slices as v1.4.18.
Wired into the analytics route — fetched 30-day data once, derived
both shares, and added `bpInTargetPct7d` / `bpInTargetPct30d` to the
response. Wired into the dashboard tile —
`avg7={data?.bpInTargetPct7d}`, `avg30={data?.bpInTargetPct30d}`.
Headline (`bpInTargetPct`) preserved for cached client bundles +
matches `last30Days` so a v1.4.17 PWA displays the same number.
## Tests (TDD red→green)
## Tests (TDD)
- **Unit** (`src/lib/analytics/__tests__/bp-in-target.test.ts`): 3 new
`computeBpInTargetWindows` cases for the all-time contract — diverging
recent vs older mix, empty-input null guard, Marc's 30-day fixture
re-asserted (10 pairs only, so allTime == 30d == 50 % is the legitimate
coincidence; the smoking-gun test layers older-history pairs to force
divergence). Failed first (`undefined` for `result.allTime`); turned
green after the helper landed.
- **Integration** (`tests/integration/bp-in-target.test.ts`): 1 new
testcontainer case seeds 40 paired readings (2 in last 7d, 10 in last
30d, 30 older, all-out-of-target older) against real Postgres and
asserts the three numbers (50 % / 50 % / 13 %) are independent.
- Full suite: 1640 / 1645 unit (5 failing pre-existed in A3's
`insights-polish.test.ts` — verified by stash-and-rerun before any
edits), 67 / 67 integration, typecheck clean, lint 12 baseline 0 new.
1. **Unit** (`src/lib/analytics/__tests__/bp-in-target.test.ts`) —
6 new cases for `computeBpInTargetWindows`: empty input,
mixed-window data, null-7d-with-real-30d, default-clock smoke,
Marc's production fixture. Failed first (`is not a function`),
went green after the helper landed.
2. **Integration** (`tests/integration/bp-in-target.test.ts`) — 2 new
testcontainer cases: seed 30 days of BP at fractional-day offsets
(avoids the boundary flakiness of integer-day seeds) and assert
both windows produce non-null hand-counted shares; second case
verifies a user with only 14-day-old data sees `7T = null,
30T = real`.
## Verification against Marc's expected values (post-deploy)
## Verification
Pre-fix tile: **50 % / 7T 50 / 30T 50** (headline=30d=50)
Post-fix tile: **11 % / 7T 50 / 30T 50** (headline=allTime ≈ 10.8 → 11)
- `pnpm test`: **1559 / 1559** green (was 1547; 8 new cases added)
- `pnpm test:integration`: **61 / 61** green (was 59; 2 new cases)
- `pnpm typecheck`: clean
- `pnpm lint`: 12 baseline warnings, **0 new**
Once deployed, the three numbers will diverge naturally for any user
whose older history shape differs from their recent 30-day window —
Marc included.
## Commit
`23363ca` —
`fix(dashboard): wire 7T and 30T sub-values on the BD-Zielbereich tile`
`a856272` —
`fix(dashboard): BD-Zielbereich headline shows all-time, not 30-day`
Pushed to origin/main on first attempt (no rebase race against the
other Wave-A agents).
Pushed to origin/main first attempt (no rebase race; A5's
`ba0d6b8` already on main, mine layered cleanly on top).