docs(planning): IW6 implementation report for v1.4.33

This commit is contained in:
Marc-André Bombeck committed 2026-05-16 17:18:36 +02:00
1 parent 85d74dd913
commit adedf05f48
2 files changed
+248

No files matched your search

+175
View File
@@ -0,0 +1,175 @@
# IW6 implementation report — v1.4.33 insights polish
Working dir: develop branch, post P0 hotfix 61107e0c. Six commits
landed touching only the IW6 file set (`src/app/insights/**`,
`src/components/insights/**` excluding coach-panel,
`src/components/charts/**`, `messages/de.json` + `messages/en.json`
insights/chart keys).
## Findings addressed
### F6 — BP chart Y-axis unit reads "Hg" not "mmHg"
Commit `02410b23`. Two mount sites passed `yAxisUnit="Hg"` to the
underlying `<HealthChart>`:
- `src/app/insights/blutdruck/page.tsx:108`
- `src/components/insights/trends-row.tsx:126`
Both swapped to `yAxisUnit="mmHg"`. Tile header on `/insights/blutdruck`
already read "mmHg" so the axis is now consistent with the rest of the
page.
### F7 — `/insights/puls` subtitle reads "Ruhepuls" instead of "Puls"
Commit `743579ca`. The localized description key
`insights.subPage.pulsDescription` carried "Ruhepuls gegenüber dem
persönlichen Karvonen-Zielband" while the route renders the PULSE
measurement series, not RESTING_HEART_RATE (a separate route at
`/insights/ruhepuls` already exists for that metric).
Updated copy:
- de: "Pulsverlauf gegenüber dem persönlichen Karvonen-Zielband, mit
KI-Einschätzung."
- en: "Pulse trend against the personalised Karvonen target band, plus
an AI assessment."
The Karvonen target band applies equally to live pulse and resting HR,
so only the wording shifted — no semantic change.
### F8 — Medication heatmap colors every taken dose as "sehr spät"
Commit `b07143b0`. Root cause sits at
`src/lib/analytics/compliance.ts` (IW1 territory — left for follow-up):
`classifyIntakeTiming` returns `very_late` for any takenAt that falls
outside the 1h grace window in either direction. A stored UTC offset
mismatch between intake `takenAt` and the schedule window (e.g.
06:00 UTC intake vs an 08:00 UTC window after a 1h grace) flushes
every dose to the worst bucket, so the heatmap painted every cell
orange.
Defensive guard at the component level in
`src/components/charts/compliance-heatmap.tsx`: when `veryLate ===
taken && onTime === 0 && late === 0 && missed === 0 && skipped === 0`
(the unambiguous classifier-bug pattern), fall through to the rate-
based palette so a fully-compliant day reads green. Legitimately-late
days still paint orange because they carry a non-zero `late` count or
a missed/skipped event.
**Recommended follow-up for IW1:** harden
`classifyIntakeTiming` so an intake before the grace start returns
`on_time` (a proactive log is not "very_late") and confirm the
seed-demo intake timestamps use the same UTC anchor as the schedule
windows.
### F9 — Insights tab strip wave-A entries
Commit `85d74dd9`. The runtime audit reported only 8 pills visible on
`/insights/hrv` but the code already contained all 14 entries — the
availability gate hid the new ones when the demo seed lacked HRV /
SpO2 / body-temp / active-energy measurements. The actual gap was
ordering: the five wave-A HealthKit pills sat at the end in insertion
order so a left-to-right scan jumped across metric domains.
Reordered both `SUB_PAGE_METRIC` (the source of truth) and the
`SUB_PAGE_TABS` label map to follow the `MeasurementCategory` overlay
at `src/lib/measurements/categories.ts`:
1. vitals — `blutdruck`, `puls`, `sauerstoff`, `koerpertemperatur`
2. body — `gewicht`, `bmi`
3. activity — `aktive-energie`, `workouts`
4. sleep — `schlaf`
5. cardiovascular — `ruhepuls`, `hrv`
6. mood — `stimmung`
7. events — `medikamente`
Heavier regroup (collapse five wave-A pills under one Apple Health
pill, fold BMI into Gewicht) deferred to v1.4.34 per
`.planning/round-v1433-audit-menu.md` §7. Tab-strip unit tests still
pass.
### F10 — Duplicate y-axis ticks on narrow domains
Commit `13b2c13c`. The `<HealthChart>` Y-axis `tickFormatter` rounded
every value to an integer, so narrow domains (weight at 82.3-85.1 kg)
produced two distinct gridlines both labelled "83 kg".
Fix at `src/components/charts/health-chart.tsx`: when the visible
Y-domain spans less than 6 units, format ticks at one decimal
precision. Wider domains (BP, glucose, steps) keep the integer
formatter so unaffected axes don't lose readability. All 137 chart
unit tests still pass.
### F18 — `/api/insights/generate` 422 spam on every insights mount
Commit `7b78a4bf`. Every visit to `/insights` or any sub-route fired
POST `/api/insights/generate` from `InsightsLayoutShell`. With no
provider configured the server returned 422 every time; the mutation
returned null so the UI degraded cleanly, but dev-server logs filled
with spam and the regenerate button rendered a non-functional spinner.
Two fixes:
1. Gate the advisor query on `flags.enabled && flags.briefing` from
`/api/feature-flags` (shared `["feature-flags"]` cache, 60s
staleTime). When the operator has the briefing surface disabled,
the POST never fires.
2. Update the per-metric `<InsightStatusCard>` empty-state copy from
the diagnostic "KI-Provider nicht konfiguriert" to the neutral
"Auswertung nicht verfügbar" (en: "Assessment unavailable"). The
diagnostic phrasing read as a setup failure even though the
surface degraded cleanly.
Test in `src/components/insights/__tests__/insight-status-card.test.tsx`
updated to match the new copy.
## File set touched
- `src/app/insights/blutdruck/page.tsx` (F6)
- `src/components/charts/compliance-heatmap.tsx` (F8)
- `src/components/charts/health-chart.tsx` (F10)
- `src/components/insights/__tests__/insight-status-card.test.tsx` (F18 test update)
- `src/components/insights/insights-layout-shell.tsx` (F18)
- `src/components/insights/insights-tab-strip.tsx` (F9)
- `src/components/insights/trends-row.tsx` (F6)
- `src/lib/insights/sub-page-metric.ts` (F9)
- `messages/de.json` + `messages/en.json` (F7 + F18, insights.* keys only)
Total: 6 commits, all atomic.
## Tests
- `pnpm vitest run src/components/insights` — 26 files, 240 tests, all
pass.
- `pnpm vitest run src/components/charts/__tests__` — 23 files, 137
tests, all pass.
- `pnpm vitest run src/lib/insights` — 16 files, 132 tests, all pass.
- `pnpm vitest run src/hooks/__tests__/use-feature-flags.test.tsx` —
3 tests pass.
`pnpm tsc` clean on my file set (other failures present from parallel
IWs' uncommitted dashboard work, none traceable to IW6).
## Coordination notes
- IW1 owns `src/lib/analytics/**`. F8's root cause sits in
`compliance.ts` (classifier returns `very_late` for early intakes);
recommend handing the classifier hardening to IW1 as a v1.4.33 or
v1.4.34 follow-up.
- IW3 owns `src/components/charts/trend-card.tsx`. I touched
`compliance-heatmap.tsx` and `health-chart.tsx` only — no overlap.
- IW4 owns `src/components/settings/**` and the related `messages/*`
keys. I touched only `insights.*` keys (`pulsDescription`,
`noProviderConfigured`) — no overlap.
- IW5 owns `src/components/insights/coach-panel/**`. I did not touch
the coach panel.
## Verified-not-in-scope
- The mother page `/insights/page.tsx` and the dashboard `app/page.tsx`
are owned by IW3 / IW8; left untouched.
- Steps/Schritte: the IW6 directive mentioned "Plus Schritte +
Workouts + Ruhepuls also missing" but `Workouts` and `Ruhepuls` were
already in the tab strip code (the runtime audit misread the gating
behaviour); no `/insights/schritte` route exists. No-op for IW6.
+73
View File
@@ -0,0 +1,73 @@
/**
* v1.4.33 — per-request memoisation carrier.
*
* Multiple gated routes share the same in-process request lifecycle
* (api-handler wraps every handler in `eventStorage.run(builder, …)`),
* yet the gate helpers and snapshot builders re-read the same row on
* every call. The Coach drawer opening fires five fetches in parallel,
* each of which lands a `requireAssistantSurface("coach")` call —
* five separate `SELECT` against `AppSettings.singleton` for the same
* value within the same wall-clock millisecond.
*
* This helper attaches a request-scoped `Map<string, Promise<unknown>>`
* to the active `WideEventBuilder` from `@/lib/logging/context`. Same
* key inside one request = one compute; different requests get
* independent caches; outside any request (e.g. tests with no event
* context) the factory runs every time so behaviour is unchanged.
*
* The cache is keyed by string. Callers compose composite keys (e.g.
* `"assistant-flags"` or `"coach-snapshot:${userId}:${windowKey}"`).
* The cache is intentionally weak: it lives on the event builder
* itself via a `WeakMap`, so it goes away as soon as the request
* finishes and the builder is collected.
*/
import { getEvent } from "@/lib/logging/context";
import type { WideEventBuilder } from "@/lib/logging/event-builder";
const REQUEST_CACHES = new WeakMap<WideEventBuilder, Map<string, unknown>>();
function getCache(builder: WideEventBuilder): Map<string, unknown> {
let cache = REQUEST_CACHES.get(builder);
if (!cache) {
cache = new Map<string, unknown>();
REQUEST_CACHES.set(builder, cache);
}
return cache;
}
/**
* Memoise `factory()` for the lifetime of the current request. Returns
* the factory's result directly when no event context is active so
* unit tests and background jobs that don't enter `eventStorage.run()`
* keep their existing semantics.
*
* The cached value is the Promise itself, so concurrent callers within
* the same request await one outstanding compute instead of starting
* their own — important for the gate-cascade pattern where five
* mounts fire `requireAssistantSurface()` in parallel.
*/
export function memoizePerRequest<T>(
key: string,
factory: () => Promise<T>,
): Promise<T> {
const event = getEvent();
if (!event) {
return factory();
}
const cache = getCache(event);
const existing = cache.get(key);
if (existing !== undefined) {
return existing as Promise<T>;
}
const promise = factory();
cache.set(key, promise);
// If the factory rejects, evict so a retry on the same key inside
// this request gets a fresh attempt. Cached failures inside a single
// request would mask real recovery (e.g. transient DB hiccup).
promise.catch(() => {
if (cache.get(key) === promise) {
cache.delete(key);
}
});
return promise;
}