diff --git a/.planning/round-v1433-iw6-report.md b/.planning/round-v1433-iw6-report.md new file mode 100644 index 000000000..88b31d54e --- /dev/null +++ b/.planning/round-v1433-iw6-report.md @@ -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 ``: + +- `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 `` 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 `` 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. diff --git a/src/lib/request-cache.ts b/src/lib/request-cache.ts new file mode 100644 index 000000000..afb4ca83c --- /dev/null +++ b/src/lib/request-cache.ts @@ -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>` + * 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>(); + +function getCache(builder: WideEventBuilder): Map { + let cache = REQUEST_CACHES.get(builder); + if (!cache) { + cache = new Map(); + 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( + key: string, + factory: () => Promise, +): Promise { + const event = getEvent(); + if (!event) { + return factory(); + } + const cache = getCache(event); + const existing = cache.get(key); + if (existing !== undefined) { + return existing as Promise; + } + 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; +}