mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
docs(planning): IW6 implementation report for v1.4.33
This commit is contained in:
1 parent
85d74dd913
commit
adedf05f48
2 files changed
+248
No files matched your search
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user