Merge v1.8.0 Insights release into main

# Conflicts:
#	CHANGELOG.md
#	docs/api/openapi.yaml
#	package.json
This commit is contained in:
Marc-André Bombeck committed 2026-05-31 23:00:08 +02:00
commit 869f13612a
115 files changed
+5736 -2737

No files matched your search

@@ -0,0 +1,80 @@
# v1.8.0 — server → iOS: Insights tile-id rename (non-breaking)
Date: 2026-05-31
From: server
To: iOS
Status: informational — no action required to keep working; action recommended before a future major
## Summary
v1.8.0 renames the Insights tile ids (and the matching web route slugs) from
German to English to make the identifier space consistent. **This is
non-breaking for the iOS layout sync.** The `GET/PUT /api/insights/layout`
endpoint keeps accepting the old German ids. You do not need to ship anything to
stay working. When convenient, migrate to the canonical English ids.
## What changed
The canonical tile ids in `GET/PUT /api/insights/layout` are now English:
| Legacy (≤ v1.7.x) | Canonical (v1.8.0+) |
|---|---|
| `blutdruck` | `blood-pressure` |
| `puls` | `pulse` |
| `sauerstoff` | `oxygen` |
| `koerpertemperatur` | `body-temperature` |
| `gewicht` | `weight` |
| `aktive-energie` | `active-energy` |
| `schlaf` | `sleep` |
| `ruhepuls` | `resting-pulse` |
| `stimmung` | `mood` |
| `medikamente` | `medications` |
`overview`, `bmi`, `hrv`, and `workouts` were already language-neutral and are
unchanged.
## Why it does not break you
- **PUT still accepts the legacy German ids.** The server's validation enum is
the union of the canonical English ids and the legacy German aliases, so a
`PUT` carrying `blutdruck` / `puls` / … validates (no 422).
- **The server normalises legacy → canonical on write.** A `PUT` with German ids
persists the English equivalents. So after any save, the stored layout is
canonical English regardless of what you sent.
- **GET always returns canonical English ids.** A layout you persisted before
v1.8.0 (German ids) is normalised on read — `GET` surfaces the English ids
without forcing a re-`PUT`.
- **Dedupe.** If a payload carries both a legacy and a canonical id for one tile,
the server keeps a single canonical entry.
So the worst case for an un-migrated iOS build is: it keeps sending German ids,
the server keeps accepting and normalising them, and on the next `GET` the app
sees English ids it does not recognise in its local enum. If your client filters
unknown tile ids defensively, that filter must learn the English ids — see
below.
## What iOS should do (recommended, before a future major)
1. **Add the English ids to your tile-id enum / model** (keep the German ones as
decode-only aliases if your decoder is strict, or just map both).
2. **Send English ids on `PUT`.** Once your build is out, you stop relying on the
server's alias acceptance.
3. **Decode `GET` responses expecting English ids.** Because the server
normalises on read, a current `GET` already returns English; an app that hard-
filters to the old German enum would drop every tile. Decode the English ids
before you rely on this endpoint in a v1.8.0+ environment.
## Deprecation timeline
The legacy German ids are accepted-but-deprecated. They will be **removed from
the accepted input set in a future major version** once we confirm clients have
migrated. No date is fixed yet; we will give notice through this channel before
the removal. Until then, both id sets validate.
## References
- Contract: `docs/api/openapi.yaml` → `InsightsLayoutBody` (lists canonical +
legacy ids, with the deprecation noted in the schema description).
- Rationale: `docs/adr/0001-insights-naming-convention.md`.
- Web route slugs got the same English rename with 301 redirects from the legacy
German URLs; that is web-only and does not affect the iOS contract.
+19
View File
@@ -1,5 +1,24 @@
# Changelog
## [1.8.0] — 2026-05-31 — Insights: reliable data-driven assessments, embedded targets, explainers
The per-metric Insights pages become the coherent heart of the app. The data-driven assessment — a short, honest reading of your own trend — now reliably reaches the page: it previously timed out and cached a generic placeholder for the rest of the day, so most users only ever saw the platitude. The generation budget is aligned with the provider, the placeholder is never cached, and the AI payload is compressed into a graded shape (recent days raw, then weekly, monthly, and yearly aggregates drawn from the rollup tier) so a heavy logger no longer ships thousands of raw points. Each metric's prompt is rewritten to a consistent house style — name the finding, place it against your own baseline, offer one concrete step; honest on poor values, never alarmist, never diagnostic. Target ranges move onto each category page beside the chart, every category gains a plain-language "what is this?" tooltip, and the assessment is warmed overnight so the page is a cache read instead of a live call. Route slugs and tile ids move to English (German tile ids stay accepted as aliases, so existing clients keep working).
### Added
- **Per-category target ranges** — each Insights category page shows its numeric target range and the share of recent days within target, beside the chart and the assessment, with a link to adjust the range. The dedicated targets page remains the editing surface.
- **Per-category explainer tooltips** — a question-mark affordance next to every category heading opens a short, plain-language definition of the metric, available offline in all six languages and reachable by keyboard and touch.
- **Nightly assessment pre-generation** — a budget-gated overnight job warms the per-metric assessment caches (and invalidates them when fresh measurements arrive), so opening a category page is a cache read rather than a synchronous model call.
- **Graded measurement compression for assessments** — a shared compressor folds a metric's history into recent (daily), weekly, monthly, and yearly buckets, drawing the monthly/yearly tiers from the rollup store, so the model receives a compact picture instead of the full daily series.
- **Naming decision record** — `docs/adr/0001-insights-naming-convention.md` documents the convention: localised UI, English internal keys and route slugs.
### Changed
- **Data-driven assessments reach the page reliably** — the per-metric assessment generation budget is aligned with the provider (was capped well below the provider floor), runs through the provider chain with fallback, and a transient timeout or error is treated as a passing miss rather than cached as a placeholder for the rest of the day.
- **Assessment wording** — every metric prompt follows one house style (name → place against own baseline → one actionable step; 2–4 sentences; honest, autonomy-supporting, non-diagnostic; says so plainly when the data is too thin). Blood pressure reads systolic and diastolic together; weight tracks the trend while BMI speaks to threshold bands; the mood assessment carries a calm support cue on a sustained low.
- **Insights route slugs are English** — `/insights/<german>` moves to `/insights/<english>` with permanent redirects from the old paths. The dashboard-layout tile ids are English canonically; the previous German ids stay accepted as aliases and are normalised on read and write, so a client that stored the old ids keeps working.
- **Per-metric assessment caches drop on measurement changes** — a fresh, edited, or deleted measurement invalidates the affected category's cached assessment so it regenerates rather than serving a stale reading.
## [1.7.3] — 2026-05-31 — Health-record PDF fixes, multi-time compliance
Two fixes to live behaviour.
@@ -0,0 +1,82 @@
# 0001 — Insights naming convention: localised UI, English identifiers
- Status: accepted
- Date: 2026-05-31
## Context
The Insights surface grew bilingually. The mother page (`/insights`) and its
routed metric sub-pages were keyed by German slugs (`/insights/blutdruck`,
`/insights/gewicht`, …) because German was the primary author voice when the
routed strip first shipped. Over time the surface accumulated a mix: route
folders, the tile-layout enum, and several internal maps were German, while
later additions (`hrv`, `oxygenSaturation`, `restingHr`, `activeEnergy`,
`workouts`) landed in English. The result was an inconsistent identifier space
where a contributor could not predict whether a given key was German or English.
Identifiers leak. A German route slug shows up in URLs, in browser history, in
the OpenAPI contract the iOS client reads, and in every internal map that pivots
on the slug. Mixed-language identifiers make the codebase harder to navigate and
make the public API contract read inconsistently.
Two surfaces also speak these identifiers across a boundary we do not control:
1. **URLs** — bookmarked and cached by the PWA.
2. **The tile-layout contract** — `GET/PUT /api/insights/layout`, which the
native iOS client persists against. The tile ids in that payload are a
client-visible contract.
## Decision
Separate the two naming axes cleanly:
- **UI copy is always localised.** Every visible string resolves through an
i18n `t()` key and is translated across all six locales (`de / en / es / fr /
it / pl`). The user never sees a raw identifier.
- **Internal identifiers are English.** Route slugs (`/insights/<slug>`), the
tile-layout enum (`INSIGHTS_TILE_IDS`), query keys, and the internal
slug→metric / slug→target maps all use established English terms. English is
the lingua franca of the codebase and of the public API contract.
Concretely, v1.8.0 migrates the routed Insights slugs and the tile-layout enum
from German to English (e.g. `blutdruck` → `blood-pressure`, `gewicht` →
`weight`, `medikamente` → `medications`). The full rename table lives in
`src/lib/insights/sub-page-metric.ts` (slugs) and `src/lib/insights-layout.ts`
(tile ids).
## Non-breaking strategy
The migration must not break either client-visible boundary:
- **URLs.** `next.config.ts` `redirects()` emits a `permanent: true` (301)
redirect from every legacy German slug to its English target, including a
`:path*` variant so any nested route survives. Bookmarks and cached PWA
navigation keep resolving.
- **Tile-layout contract.** The layout endpoint keeps **accepting** the legacy
German tile ids on input. The Zod enum is built from
`ACCEPTED_INSIGHTS_TILE_IDS` (canonical English ids *plus* the legacy German
aliases), so a `PUT` carrying old ids validates rather than tripping a 422.
`normalizeInsightsTileId` collapses each legacy id onto its canonical English
replacement; this normalisation runs on **both** the read path
(`resolveInsightsLayout`) and the write path (`serializeInsightsLayout`), so:
- a layout persisted by a `≤ v1.7.x` client surfaces canonical English ids on
`GET` without forcing a re-`PUT`;
- a `PUT` sending legacy ids persists canonical English ids;
- a layout that carries both a legacy and a canonical id for one tile dedupes
to a single canonical entry.
The legacy ids are accepted-but-deprecated. They will be removed from the
accepted set in a future major version once telemetry shows clients have
migrated. The OpenAPI `InsightsLayoutBody` schema documents the canonical ids,
the accepted legacy ids, and the deprecation.
## Consequences
- The Insights identifier space is uniformly English and predictable.
- No client breaks on upgrade: old URLs redirect, old tile ids normalise.
- The legacy-alias map and the redirect table are deliberate, finite debt to be
retired in a future major. Until then they are covered by tests
(`src/app/api/insights/layout/__tests__/route.test.ts`) that assert the
non-breaking round-trip.
- New Insights surfaces follow the convention from the start: English slug,
English tile id, localised `t()` copy.
+37 -9
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.7.3
version: 1.8.0
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -2440,17 +2440,27 @@ components:
type: string
enum:
- overview
- blood-pressure
- pulse
- oxygen
- body-temperature
- weight
- bmi
- active-energy
- workouts
- sleep
- resting-pulse
- hrv
- mood
- medications
- blutdruck
- puls
- sauerstoff
- koerpertemperatur
- gewicht
- bmi
- aktive-energie
- workouts
- schlaf
- ruhepuls
- hrv
- stimmung
- medikamente
visible:
@@ -2467,7 +2477,11 @@ components:
- version
- tiles
description: "Per-user Insights tile layout: an ordered list of tiles with a visibility flag. `version` is the layout
schema version; tile ids are a closed enum derived from the server's tile registry."
schema version. Tile ids are a closed enum: the canonical ids are English (matching the routed
`/insights/<slug>` sub-pages). The legacy German ids (blutdruck, puls, sauerstoff, koerpertemperatur, gewicht,
aktive-energie, schlaf, ruhepuls, stimmung, medikamente) remain accepted on input for backward compatibility and
are normalised to their English equivalents before persisting; GET responses always carry the canonical English
ids. The legacy ids are deprecated and will be removed in a future major version."
CoachMessageFeedbackRequest:
type: object
properties:
@@ -5935,17 +5949,27 @@ components:
type: string
enum:
- overview
- blood-pressure
- pulse
- oxygen
- body-temperature
- weight
- bmi
- active-energy
- workouts
- sleep
- resting-pulse
- hrv
- mood
- medications
- blutdruck
- puls
- sauerstoff
- koerpertemperatur
- gewicht
- bmi
- aktive-energie
- workouts
- schlaf
- ruhepuls
- hrv
- stimmung
- medikamente
visible:
@@ -5964,7 +5988,11 @@ components:
- tiles
additionalProperties: false
description: "Per-user Insights tile layout: an ordered list of tiles with a visibility flag. `version` is the layout
schema version; tile ids are a closed enum derived from the server's tile registry."
schema version. Tile ids are a closed enum: the canonical ids are English (matching the routed
`/insights/<slug>` sub-pages). The legacy German ids (blutdruck, puls, sauerstoff, koerpertemperatur, gewicht,
aktive-energie, schlaf, ruhepuls, stimmung, medikamente) remain accepted on input for backward compatibility and
are normalised to their English equivalents before persisting; GET responses always carry the canonical English
ids. The legacy ids are deprecated and will be removed in a future major version."
Medication:
type: object
properties:
+84 -1
View File
@@ -1800,7 +1800,90 @@
"medikamenteEmptyDescription": "Adhärenz-Insights brauchen mindestens ein erfasstes Medikament.",
"medikamenteEmptyAction": "Medikamente öffnen",
"therapyTimelineDescription": "Chronologisches GLP-1-Therapieprotokoll — Dosis-Änderungen, Injektionen, Bestand, Nebenwirkungen.",
"bmiDescription": "BMI aus deinem Gewicht und deiner Größe abgeleitet, mit Einschätzung."
"bmiDescription": "BMI aus deinem Gewicht und deiner Größe abgeleitet, mit Einschätzung.",
"target": {
"range": "Ziel: {min}–{max} {unit}",
"bpRange": "Ziel: {sysMin}–{sysMax} / {diaMin}–{diaMax} {unit}",
"inTargetShare": "{pct}% der erfassten Tage im Zielbereich",
"adjustLink": "Zielbereich anpassen"
},
"explainer": {
"trigger": "Was ist {title}?",
"bloodPressureTitle": "Blutdruck",
"bloodPressureBody": "Der Blutdruck ist die Kraft, die das Blut auf die Gefäßwände ausübt, angegeben als systolisch zu diastolisch (Spitzenwert beim Herzschlag über dem Ruhewert dazwischen). Dauerhaft hohe Werte belasten Herz und Gefäße auf Dauer.",
"pulseTitle": "Puls",
"pulseBody": "Der Puls gibt an, wie oft das Herz pro Minute schlägt. Eine Einzelmessung spiegelt den Moment wider – in Ruhe liegt er meist bei 60–100 Schlägen pro Minute und steigt natürlicherweise bei Bewegung, Stress oder Koffein.",
"weightTitle": "Gewicht",
"weightBody": "Das Körpergewicht ist eines der einfachsten Signale für die langfristige Energiebilanz und die allgemeine Gesundheit. Der Trend über Wochen zählt weit mehr als ein einzelner Wert, der mit Flüssigkeit, Ernährung und Tageszeit schwankt.",
"bmiTitle": "Body-Mass-Index (BMI)",
"bmiBody": "Der BMI ist das Gewicht geteilt durch die Körpergröße zum Quadrat und ordnet Sie einer groben Gewichtskategorie zu. Er ist ein Orientierungswert, keine Diagnose, und unterscheidet nicht zwischen Muskeln und Fett.",
"sleepTitle": "Schlaf",
"sleepBody": "Die Schlafdauer ist die tatsächlich geschlafene Zeit pro Nacht, aufgeteilt in Leicht-, Tief- und REM-Schlaf. Die meisten Erwachsenen brauchen etwa sieben bis neun Stunden; gleichmäßige Menge und Qualität unterstützen Erholung, Stimmung und Stoffwechsel.",
"moodTitle": "Stimmung",
"moodBody": "Die Stimmungserfassung hält auf einer einfachen Skala fest, wie Sie sich fühlen. Regelmäßiges Protokollieren zeigt Muster – wie Schlaf, Aktivität oder Medikamente mit dem Befinden zusammenhängen –, die ein einzelner Tag nie offenbart.",
"medicationsTitle": "Medikamenten-Therapietreue",
"medicationsBody": "Die Therapietreue zeigt, wie zuverlässig Sie Ihre Medikamente wie verordnet einnehmen, hier als Anteil der erwarteten Dosen, die Sie erfasst haben. Eine gleichmäßige Einnahme entscheidet oft darüber, ob eine Therapie wie beabsichtigt wirkt.",
"restingHrTitle": "Ruhepuls",
"restingHrBody": "Der Ruhepuls ist die Herzfrequenz in völliger Ruhe, meist über Nacht gemessen. Ein niedriger Ruhepuls deutet in der Regel auf eine bessere Herz-Kreislauf-Fitness hin, ein steigender Trend kann ein frühes Zeichen für Stress oder Krankheit sein.",
"hrvTitle": "Herzfrequenzvariabilität (HRV)",
"hrvBody": "Die HRV ist die kleine Schwankung der Zeit zwischen aufeinanderfolgenden Herzschlägen. Eine höhere Variabilität spiegelt meist ein gut erholtes, anpassungsfähiges Nervensystem wider, ein anhaltender Rückgang kann auf Erschöpfung, Stress oder mangelnde Erholung hinweisen.",
"oxygenSaturationTitle": "Sauerstoffsättigung (SpO₂)",
"oxygenSaturationBody": "Die SpO₂ ist der Prozentsatz der roten Blutkörperchen, die Sauerstoff transportieren. Werte von 95–100 % sind in Ruhe normal; Werte, die dauerhaft unter 90 % liegen, verdienen Aufmerksamkeit, kurze nächtliche Abfälle sind jedoch häufig.",
"bodyTemperatureTitle": "Körpertemperatur",
"bodyTemperatureBody": "Die Körpertemperatur spiegelt Ihren Kernwärmezustand wider, normalerweise um 37 °C mit kleinen Tagesschwankungen. Ein anhaltender Anstieg kann auf eine Infektion hindeuten; nächtliche Messungen am Handgelenk erfassen vor allem relative Änderungen, keinen klinischen Wert.",
"activeEnergyTitle": "Aktive Energie",
"activeEnergyBody": "Die aktive Energie sind die Kalorien, die Sie durch Bewegung verbrennen, zusätzlich zum Grundumsatz in Ruhe. Sie ist ein direktes Maß dafür, wie körperlich aktiv Ihr Tag war.",
"workoutsTitle": "Workouts",
"workoutsBody": "Workouts sind die strukturierten Trainingseinheiten, die Ihre Geräte mit Art, Dauer und Energie aufzeichnen. Ihre Erfassung zeigt, wie sich regelmäßiges Training über Wochen summiert und wie es mit Ihren anderen Gesundheitssignalen zusammenhängt.",
"respiratoryRateTitle": "Atemfrequenz",
"respiratoryRateBody": "Die Atemfrequenz ist die Anzahl der Atemzüge pro Minute. In Ruhe atmen die meisten Erwachsenen etwa zwölf- bis zwanzigmal pro Minute; ein anhaltender Anstieg kann mit Krankheit, Anstrengung oder Stress einhergehen.",
"bodyWaterTitle": "Gesamtkörperwasser",
"bodyWaterBody": "Das Gesamtkörperwasser ist der Anteil deines Körpergewichts, der aus Flüssigkeit in und um die Zellen besteht. Es schwankt mit Flüssigkeitshaushalt, Ernährung und Aktivität, daher sagt der Trend über Wochen mehr aus als ein einzelner Wert.",
"boneMassTitle": "Knochenmasse",
"boneMassBody": "Die Knochenmasse ist das geschätzte Gewicht der Mineralien in deinem Skelett. Waagengestützte Schätzungen sind ungefähr, doch ein gleichbleibender oder steigender Trend unterstützt die langfristige Knochenstärke.",
"fatFreeMassTitle": "Fettfreie Masse",
"fatFreeMassBody": "Die fettfreie Masse ist alles in deinem Körper, das kein Fett ist — Muskeln, Knochen, Organe und Wasser. Sie zu halten oder aufzubauen, während das Gewicht sinkt, deutet meist auf eine gesündere Körperzusammensetzung hin.",
"fatMassTitle": "Fettmasse",
"fatMassBody": "Die Fettmasse ist das Gesamtgewicht des Körperfetts. Etwas Fett ist für Hormone und Energiespeicher unverzichtbar, doch zu viel — besonders am Bauch — erhöht das langfristige Gesundheitsrisiko.",
"muscleMassTitle": "Muskelmasse",
"muscleMassBody": "Die Muskelmasse ist das geschätzte Gewicht der Muskeln in deinem Körper. Mehr Muskeln unterstützen Kraft, Stoffwechsel und Beweglichkeit und müssen mit zunehmendem Alter aktiv erhalten werden.",
"visceralFatTitle": "Viszeralfett",
"visceralFatBody": "Viszeralfett ist das Fett, das tief im Bauchraum um die Organe gespeichert wird. Anders als das Unterhautfett ist ein hoher Wert eng mit Herzkrankheiten und Typ-2-Diabetes verbunden, daher ist niedriger meist besser.",
"leanBodyMassTitle": "Magermasse",
"leanBodyMassBody": "Die Magermasse ist dein Gesamtgewicht abzüglich des Körperfetts — Muskeln, Knochen, Organe und Wasser zusammen. Sie neben dem Gewicht zu verfolgen zeigt, ob Veränderungen aus Muskeln oder aus Fett stammen.",
"flightsClimbedTitle": "Etagen gestiegen",
"flightsClimbedBody": "Die gestiegenen Etagen zählen die Stockwerke, die du täglich nach oben gehst, wobei eine Etage etwa drei Metern Höhe entspricht. Es ist ein einfacher Marker für Alltagsaktivität und Beinarbeit.",
"walkingDistanceTitle": "Geh- + Laufstrecke",
"walkingDistanceBody": "Die Geh- und Laufstrecke ist die Strecke, die du täglich zu Fuß zurücklegst. Sie ergänzt die Schrittzahl, indem sie zeigt, wie weit du dich bewegt hast, nicht nur wie oft du getreten bist.",
"walkingSteadinessTitle": "Gehstabilität",
"walkingSteadinessBody": "Die Gehstabilität schätzt, wie stabil und ausgewogen dein Gangbild ist, eingeteilt von OK bis niedrig. Ein niedriger Wert deutet auf ein höheres Sturzrisiko hin und sollte ärztlich besprochen werden.",
"walkingHeartRateTitle": "Herzfrequenz beim Gehen",
"walkingHeartRateBody": "Die Herzfrequenz beim Gehen ist dein durchschnittlicher Puls bei einem gleichmäßigen Spaziergang. Ein über die Zeit niedrigerer Wert spiegelt bei gleicher Anstrengung oft eine bessere Herz-Kreislauf-Fitness wider.",
"walkingAsymmetryTitle": "Gangasymmetrie",
"walkingAsymmetryBody": "Die Gangasymmetrie ist der Anteil der Zeit, in der deine Schritte zwischen linkem und rechtem Bein ungleichmäßig sind. Ein höherer Prozentsatz kann auf Schmerzen, eine Verletzung oder ein Gangproblem hindeuten, das näher betrachtet werden sollte.",
"doubleSupportTimeTitle": "Doppelunterstützungszeit",
"doubleSupportTimeBody": "Die Doppelunterstützungszeit ist der Anteil jedes Gehzyklus, in dem beide Füße gleichzeitig den Boden berühren. Ein höherer Prozentsatz bedeutet meist ein vorsichtigeres, weniger stabiles Gangbild.",
"stepLengthTitle": "Schrittlänge",
"stepLengthBody": "Die Schrittlänge ist der durchschnittliche Abstand von einem Fußauftritt zum nächsten. Eine über die Zeit kürzere Schrittlänge kann Müdigkeit, Vorsicht oder eine veränderte Beweglichkeit widerspiegeln.",
"walkingSpeedTitle": "Gehgeschwindigkeit",
"walkingSpeedBody": "Die Gehgeschwindigkeit ist, wie schnell du dich auf ebenem Untergrund fortbewegst. Sie ist ein gut untersuchter Marker für allgemeine Gesundheit und Beweglichkeit, und ein gleichbleibendes oder steigendes Tempo ist ein gutes Zeichen.",
"pulseWaveVelocityTitle": "Pulswellengeschwindigkeit",
"pulseWaveVelocityBody": "Die Pulswellengeschwindigkeit misst, wie schnell die Druckwelle jedes Herzschlags durch deine Arterien läuft. Eine schnellere Welle weist auf steifere Arterien hin, was mit einem höheren Herz-Kreislauf-Risiko verbunden ist.",
"vascularAgeTitle": "Gefäßalter",
"vascularAgeBody": "Das Gefäßalter schätzt anhand der Arteriensteifigkeit, wie alt sich deine Arterien im Vergleich zu deinem tatsächlichen Alter verhalten. Ein Gefäßalter unter dem echten Alter deutet auf gesündere Blutgefäße hin.",
"environmentalAudioTitle": "Umgebungslautstärke",
"environmentalAudioBody": "Die Umgebungslautstärke erfasst die Lautstärke deiner Umwelt in Dezibel. Anhaltende Belastung über etwa 70 dB kann das Gehör mit der Zeit schädigen, daher sind niedrigere Tagesmittel sicherer.",
"headphoneAudioTitle": "Kopfhörerlautstärke",
"headphoneAudioBody": "Die Kopfhörerlautstärke erfasst in Dezibel, wie laut du über Ohrhörer oder Kopfhörer hörst. Die Lautstärke niedrig zu halten und Pausen einzulegen schützt dein Gehör langfristig.",
"audioEventsTitle": "Laute Geräuschereignisse",
"audioEventsBody": "Laute Geräuschereignisse markieren Momente, in denen der Lärm um dich herum ein Niveau erreicht hat, das bei ausreichender Belastung das Gehör schädigen kann. Häufige Ereignisse sind ein Hinweis, eine ruhigere Umgebung oder Gehörschutz zu suchen.",
"daylightTitle": "Zeit im Tageslicht",
"daylightBody": "Die Zeit im Tageslicht ist, wie lange du dich täglich im Freien im Licht aufhältst. Regelmäßiges Tageslicht unterstützt einen gesunden Schlaf-Wach-Rhythmus und die Stimmung, und Zeit im Freien ist mit der Augengesundheit verbunden.",
"bloodGlucoseTitle": "Blutzucker",
"bloodGlucoseBody": "Der Blutzucker ist die Zuckermenge in deinem Blut, die wichtigste Energiequelle des Körpers. Er steigt nach dem Essen und pendelt sich zwischen den Mahlzeiten ein; dauerhaft hohe Werte können auf Diabetes oder Prädiabetes hinweisen.",
"skinTemperatureTitle": "Hauttemperatur",
"skinTemperatureBody": "Die Hauttemperatur ist hier die kleine nächtliche Abweichung deiner Handgelenkstemperatur von deinem eigenen Ausgangswert, keine absolute Fiebermessung. Veränderungen können Schlaf, Umgebung, Krankheit oder Zyklusphase widerspiegeln."
}
},
"sleep": {
"title": "Schlaf",
+84 -1
View File
@@ -1800,7 +1800,90 @@
"medikamenteEmptyDescription": "Compliance insights need at least one tracked medication.",
"medikamenteEmptyAction": "Open medications",
"therapyTimelineDescription": "Chronological GLP-1 therapy log — dose changes, injections, inventory, side effects.",
"bmiDescription": "BMI derived from your weight and height, with a written assessment."
"bmiDescription": "BMI derived from your weight and height, with a written assessment.",
"target": {
"range": "Target: {min}–{max} {unit}",
"bpRange": "Target: {sysMin}–{sysMax} / {diaMin}–{diaMax} {unit}",
"inTargetShare": "{pct}% of logged days within target",
"adjustLink": "Adjust target range"
},
"explainer": {
"trigger": "What is {title}?",
"bloodPressureTitle": "Blood pressure",
"bloodPressureBody": "Blood pressure is the force your blood exerts on the artery walls, written as systolic over diastolic (the peak during a heartbeat over the resting value between beats). Consistently high readings strain the heart and blood vessels over time.",
"pulseTitle": "Pulse",
"pulseBody": "Your pulse is how many times your heart beats per minute. A spot reading reflects what you were doing in the moment — at rest it is typically 60–100 beats per minute, and it rises naturally with movement, stress, or caffeine.",
"weightTitle": "Weight",
"weightBody": "Body weight is one of the simplest signals of long-term energy balance and overall health. The trend over weeks matters far more than any single reading, which fluctuates with hydration, food, and time of day.",
"bmiTitle": "Body mass index (BMI)",
"bmiBody": "BMI is your weight divided by your height squared, giving a single number that places you in a broad weight category. It is a population-level screen, not a diagnosis, and does not distinguish muscle from fat.",
"sleepTitle": "Sleep",
"sleepBody": "Sleep duration is how long you actually slept across the night, split into light, deep, and REM stages. Most adults need roughly seven to nine hours; both consistent quantity and quality support recovery, mood, and metabolism.",
"moodTitle": "Mood",
"moodBody": "Mood tracking records how you feel over time on a simple scale. Logging it regularly reveals patterns — how sleep, activity, or medication line up with how you feel — that a single day never shows.",
"medicationsTitle": "Medication adherence",
"medicationsBody": "Adherence is how reliably you take your medication as prescribed, shown here as the share of expected doses you actually logged. Steady intake is often what makes a treatment work as intended.",
"restingHrTitle": "Resting heart rate",
"restingHrBody": "Resting heart rate is your heart rate when you are completely at rest, usually measured overnight. A lower resting rate generally points to better cardiovascular fitness, and a rising trend can be an early sign of stress or illness.",
"hrvTitle": "Heart rate variability (HRV)",
"hrvBody": "HRV is the tiny variation in time between consecutive heartbeats. Higher variability usually reflects a well-recovered, adaptable nervous system, while a sustained drop can signal fatigue, stress, or insufficient recovery.",
"oxygenSaturationTitle": "Oxygen saturation (SpO₂)",
"oxygenSaturationBody": "SpO₂ is the percentage of your red blood cells carrying oxygen. Readings of 95–100% are normal at rest; values that stay below 90% warrant attention, though brief nightly dips are common.",
"bodyTemperatureTitle": "Body temperature",
"bodyTemperatureBody": "Body temperature reflects your core thermal state, normally around 37 °C with small daily swings. A sustained rise can indicate infection, while overnight wrist readings mainly track relative changes rather than a clinical value.",
"activeEnergyTitle": "Active energy",
"activeEnergyBody": "Active energy is the calories you burn through movement, on top of the baseline your body uses at rest. It is a direct measure of how physically active your day was.",
"workoutsTitle": "Workouts",
"workoutsBody": "Workouts are the structured exercise sessions your devices record, with type, duration, and energy. Tracking them shows how regular training accumulates over weeks and how it relates to your other health signals.",
"respiratoryRateTitle": "Respiratory rate",
"respiratoryRateBody": "Respiratory rate is the number of breaths you take per minute. At rest most adults breathe roughly twelve to twenty times a minute; a sustained rise can accompany illness, exertion, or stress.",
"bodyWaterTitle": "Total body water",
"bodyWaterBody": "Total body water is the share of your body weight made up of fluid, inside and around your cells. It shifts with hydration, food, and activity, so the trend over weeks tells you more than any single reading.",
"boneMassTitle": "Bone mass",
"boneMassBody": "Bone mass is the estimated weight of the mineral in your skeleton. Scale-based estimates are approximate, but a steady or rising trend supports long-term bone strength.",
"fatFreeMassTitle": "Fat-free mass",
"fatFreeMassBody": "Fat-free mass is everything in your body that is not fat — muscle, bone, organs, and water. Holding or building it while losing weight usually signals healthier body composition.",
"fatMassTitle": "Fat mass",
"fatMassBody": "Fat mass is the total weight of fat in your body. Some fat is essential for hormones and energy storage, but too much — especially around the abdomen — raises long-term health risk.",
"muscleMassTitle": "Muscle mass",
"muscleMassBody": "Muscle mass is the estimated weight of the muscle in your body. More muscle supports strength, metabolism, and mobility, and it tends to need active effort to preserve as you age.",
"visceralFatTitle": "Visceral fat",
"visceralFatBody": "Visceral fat is the fat stored deep in the abdomen around your organs. Unlike fat under the skin, a high level is closely linked to heart disease and type 2 diabetes, so lower is generally better.",
"leanBodyMassTitle": "Lean body mass",
"leanBodyMassBody": "Lean body mass is your total weight minus body fat — muscle, bone, organs, and water combined. Tracking it alongside weight shows whether changes come from muscle or from fat.",
"flightsClimbedTitle": "Flights climbed",
"flightsClimbedBody": "Flights climbed counts the floors you go up each day, with one flight roughly ten feet of elevation. It is a simple marker of incidental activity and lower-body effort.",
"walkingDistanceTitle": "Walking + running distance",
"walkingDistanceBody": "Walking and running distance is the ground you cover on foot each day. It complements step count by reflecting how far you moved, not just how often you stepped.",
"walkingSteadinessTitle": "Walking steadiness",
"walkingSteadinessBody": "Walking steadiness estimates how stable and balanced your gait is, classed from OK to low. A lower level points to a higher chance of a fall and is worth discussing with a clinician.",
"walkingHeartRateTitle": "Walking heart rate",
"walkingHeartRateBody": "Walking heart rate is your average pulse during a steady walk. A lower walking rate over time often reflects improving cardiovascular fitness for the same effort.",
"walkingAsymmetryTitle": "Walking asymmetry",
"walkingAsymmetryBody": "Walking asymmetry is the share of time your steps are uneven between the left and right leg. A higher percentage can point to pain, injury, or a gait problem worth looking into.",
"doubleSupportTimeTitle": "Double support time",
"doubleSupportTimeBody": "Double support time is the share of each walking cycle when both feet touch the ground at once. A higher percentage usually means a more cautious, less stable gait.",
"stepLengthTitle": "Step length",
"stepLengthBody": "Step length is the average distance from one footfall to the next. A shorter step over time can reflect fatigue, caution, or a change in mobility.",
"walkingSpeedTitle": "Walking speed",
"walkingSpeedBody": "Walking speed is how fast you move on a level walk. It is a well-studied marker of overall health and mobility, and a steady or rising pace is a good sign.",
"pulseWaveVelocityTitle": "Pulse wave velocity",
"pulseWaveVelocityBody": "Pulse wave velocity measures how fast the pressure wave from each heartbeat travels along your arteries. A faster wave indicates stiffer arteries, which is linked to higher cardiovascular risk.",
"vascularAgeTitle": "Vascular age",
"vascularAgeBody": "Vascular age estimates how old your arteries behave compared with your actual age, based on arterial stiffness. A vascular age below your real age suggests healthier blood vessels.",
"environmentalAudioTitle": "Environmental sound levels",
"environmentalAudioBody": "Environmental sound levels track the loudness of the world around you in decibels. Prolonged exposure above about 70 dB can damage hearing over time, so lower daily averages are safer.",
"headphoneAudioTitle": "Headphone audio levels",
"headphoneAudioBody": "Headphone audio levels track how loudly you listen through earbuds or headphones, in decibels. Keeping the volume down and taking breaks protects your hearing over the long run.",
"audioEventsTitle": "Loud sound events",
"audioEventsBody": "Loud sound events flag moments when noise around you reached a level that can harm hearing with enough exposure. Frequent events are a cue to seek quieter surroundings or hearing protection.",
"daylightTitle": "Time in daylight",
"daylightBody": "Time in daylight is how long you spend in outdoor light each day. Regular daylight supports a healthy sleep-wake rhythm and mood, and time outdoors is linked to eye health.",
"bloodGlucoseTitle": "Blood glucose",
"bloodGlucoseBody": "Blood glucose is the amount of sugar in your blood, the body's main energy source. It rises after eating and settles between meals; readings that stay high over time can signal diabetes or prediabetes.",
"skinTemperatureTitle": "Skin temperature",
"skinTemperatureBody": "Skin temperature here is the small nightly change in your wrist temperature from your own baseline, not an absolute fever reading. Shifts can track sleep, environment, illness, or menstrual-cycle phase."
}
},
"sleep": {
"title": "Sleep",
+84 -1
View File
@@ -1800,7 +1800,90 @@
"medikamenteEmptyDescription": "Los insights de adherencia necesitan al menos un medicamento registrado.",
"medikamenteEmptyAction": "Abrir medicamentos",
"therapyTimelineDescription": "Protocolo cronológico de terapia GLP-1 — cambios de dosis, inyecciones, existencias, efectos secundarios.",
"bmiDescription": "IMC derivado de tu peso y altura, con valoración."
"bmiDescription": "IMC derivado de tu peso y altura, con valoración.",
"target": {
"range": "Objetivo: {min}–{max} {unit}",
"bpRange": "Objetivo: {sysMin}–{sysMax} / {diaMin}–{diaMax} {unit}",
"inTargetShare": "{pct}% de los días registrados dentro del objetivo",
"adjustLink": "Ajustar el rango objetivo"
},
"explainer": {
"trigger": "¿Qué es {title}?",
"bloodPressureTitle": "Presión arterial",
"bloodPressureBody": "La presión arterial es la fuerza que ejerce la sangre sobre las paredes de las arterias, expresada como sistólica sobre diastólica (el pico durante un latido sobre el valor en reposo entre latidos). Los valores altos de forma constante sobrecargan el corazón y los vasos con el tiempo.",
"pulseTitle": "Pulso",
"pulseBody": "El pulso es cuántas veces late el corazón por minuto. Una medición puntual refleja lo que hacías en ese momento: en reposo suele ser de 60 a 100 latidos por minuto y sube de forma natural con el movimiento, el estrés o la cafeína.",
"weightTitle": "Peso",
"weightBody": "El peso corporal es una de las señales más simples del balance energético a largo plazo y de la salud general. La tendencia a lo largo de las semanas importa mucho más que cualquier lectura individual, que varía con la hidratación, la comida y la hora del día.",
"bmiTitle": "Índice de masa corporal (IMC)",
"bmiBody": "El IMC es tu peso dividido por tu altura al cuadrado, un único número que te sitúa en una categoría amplia de peso. Es un cribado poblacional, no un diagnóstico, y no distingue el músculo de la grasa.",
"sleepTitle": "Sueño",
"sleepBody": "La duración del sueño es cuánto dormiste realmente durante la noche, dividido en fases ligera, profunda y REM. La mayoría de los adultos necesita entre siete y nueve horas; tanto la cantidad como la calidad constantes favorecen la recuperación, el ánimo y el metabolismo.",
"moodTitle": "Estado de ánimo",
"moodBody": "El registro del estado de ánimo anota cómo te sientes con el tiempo en una escala sencilla. Anotarlo con regularidad revela patrones —cómo el sueño, la actividad o la medicación se relacionan con cómo te sientes— que un solo día nunca muestra.",
"medicationsTitle": "Adherencia a la medicación",
"medicationsBody": "La adherencia es la fiabilidad con la que tomas tu medicación según lo prescrito, aquí como la proporción de dosis previstas que registraste. Una toma constante suele ser lo que hace que un tratamiento funcione como se pretende.",
"restingHrTitle": "Frecuencia cardíaca en reposo",
"restingHrBody": "La frecuencia cardíaca en reposo es tu ritmo cardíaco cuando estás completamente en reposo, normalmente medido durante la noche. Una frecuencia más baja suele indicar mejor forma cardiovascular, y una tendencia al alza puede ser una señal temprana de estrés o enfermedad.",
"hrvTitle": "Variabilidad de la frecuencia cardíaca (VFC)",
"hrvBody": "La VFC es la pequeña variación en el tiempo entre latidos consecutivos. Una mayor variabilidad suele reflejar un sistema nervioso bien recuperado y adaptable, mientras que una caída sostenida puede señalar fatiga, estrés o recuperación insuficiente.",
"oxygenSaturationTitle": "Saturación de oxígeno (SpO₂)",
"oxygenSaturationBody": "La SpO₂ es el porcentaje de glóbulos rojos que transportan oxígeno. Los valores de 95 a 100 % son normales en reposo; los valores que se mantienen por debajo del 90 % merecen atención, aunque las bajadas breves nocturnas son frecuentes.",
"bodyTemperatureTitle": "Temperatura corporal",
"bodyTemperatureBody": "La temperatura corporal refleja tu estado térmico central, normalmente en torno a 37 °C con pequeñas variaciones diarias. Un aumento sostenido puede indicar una infección, mientras que las lecturas nocturnas en la muñeca reflejan sobre todo cambios relativos, no un valor clínico.",
"activeEnergyTitle": "Energía activa",
"activeEnergyBody": "La energía activa son las calorías que quemas con el movimiento, además del consumo basal en reposo. Es una medida directa de lo físicamente activo que fue tu día.",
"workoutsTitle": "Entrenamientos",
"workoutsBody": "Los entrenamientos son las sesiones de ejercicio estructurado que registran tus dispositivos, con tipo, duración y energía. Registrarlos muestra cómo el entrenamiento regular se acumula a lo largo de las semanas y cómo se relaciona con tus otras señales de salud.",
"respiratoryRateTitle": "Frecuencia respiratoria",
"respiratoryRateBody": "La frecuencia respiratoria es el número de respiraciones que haces por minuto. En reposo, la mayoría de los adultos respiran entre doce y veinte veces por minuto; un aumento sostenido puede acompañar a una enfermedad, el esfuerzo o el estrés.",
"bodyWaterTitle": "Agua corporal total",
"bodyWaterBody": "El agua corporal total es la parte de tu peso compuesta por líquido, dentro y alrededor de las células. Varía con la hidratación, la comida y la actividad, así que la tendencia a lo largo de semanas dice más que una sola medición.",
"boneMassTitle": "Masa ósea",
"boneMassBody": "La masa ósea es el peso estimado del mineral de tu esqueleto. Las estimaciones de la báscula son aproximadas, pero una tendencia estable o al alza favorece la fortaleza ósea a largo plazo.",
"fatFreeMassTitle": "Masa libre de grasa",
"fatFreeMassBody": "La masa libre de grasa es todo lo que en tu cuerpo no es grasa: músculo, hueso, órganos y agua. Mantenerla o aumentarla mientras pierdes peso suele indicar una composición corporal más sana.",
"fatMassTitle": "Masa grasa",
"fatMassBody": "La masa grasa es el peso total de la grasa de tu cuerpo. Algo de grasa es esencial para las hormonas y la reserva de energía, pero un exceso —sobre todo en el abdomen— eleva el riesgo para la salud a largo plazo.",
"muscleMassTitle": "Masa muscular",
"muscleMassBody": "La masa muscular es el peso estimado del músculo de tu cuerpo. Más músculo favorece la fuerza, el metabolismo y la movilidad, y suele requerir esfuerzo activo para conservarse con la edad.",
"visceralFatTitle": "Grasa visceral",
"visceralFatBody": "La grasa visceral es la grasa almacenada en lo profundo del abdomen, alrededor de los órganos. A diferencia de la grasa bajo la piel, un nivel alto se asocia estrechamente con enfermedad cardíaca y diabetes tipo 2, por lo que cuanto más baja, mejor.",
"leanBodyMassTitle": "Masa magra corporal",
"leanBodyMassBody": "La masa magra corporal es tu peso total menos la grasa: músculo, hueso, órganos y agua en conjunto. Seguirla junto al peso muestra si los cambios vienen del músculo o de la grasa.",
"flightsClimbedTitle": "Pisos subidos",
"flightsClimbedBody": "Los pisos subidos cuentan las plantas que subes cada día, con un piso equivalente a unos tres metros de altura. Es un indicador sencillo de la actividad cotidiana y el esfuerzo del tren inferior.",
"walkingDistanceTitle": "Distancia a pie y corriendo",
"walkingDistanceBody": "La distancia a pie y corriendo es el terreno que recorres caminando cada día. Complementa el recuento de pasos al reflejar cuánto te moviste, no solo con qué frecuencia diste pasos.",
"walkingSteadinessTitle": "Estabilidad al caminar",
"walkingSteadinessBody": "La estabilidad al caminar estima cuán estable y equilibrada es tu marcha, clasificada de correcta a baja. Un nivel bajo indica mayor probabilidad de caída y conviene comentarlo con un profesional.",
"walkingHeartRateTitle": "Frecuencia cardíaca al caminar",
"walkingHeartRateBody": "La frecuencia cardíaca al caminar es tu pulso medio durante una caminata constante. Un valor más bajo con el tiempo suele reflejar una mejor forma cardiovascular para el mismo esfuerzo.",
"walkingAsymmetryTitle": "Asimetría al caminar",
"walkingAsymmetryBody": "La asimetría al caminar es la proporción de tiempo en que tus pasos son desiguales entre la pierna izquierda y la derecha. Un porcentaje más alto puede indicar dolor, lesión o un problema de marcha que conviene revisar.",
"doubleSupportTimeTitle": "Tiempo de doble apoyo",
"doubleSupportTimeBody": "El tiempo de doble apoyo es la proporción de cada ciclo de marcha en que ambos pies tocan el suelo a la vez. Un porcentaje más alto suele indicar una marcha más cautelosa y menos estable.",
"stepLengthTitle": "Longitud de zancada",
"stepLengthBody": "La longitud de zancada es la distancia media de una pisada a la siguiente. Una zancada más corta con el tiempo puede reflejar fatiga, cautela o un cambio en la movilidad.",
"walkingSpeedTitle": "Velocidad al caminar",
"walkingSpeedBody": "La velocidad al caminar es lo rápido que te mueves en terreno llano. Es un marcador bien estudiado de la salud general y la movilidad, y un ritmo estable o al alza es buena señal.",
"pulseWaveVelocityTitle": "Velocidad de la onda de pulso",
"pulseWaveVelocityBody": "La velocidad de la onda de pulso mide la rapidez con que la onda de presión de cada latido viaja por tus arterias. Una onda más rápida indica arterias más rígidas, lo que se asocia a mayor riesgo cardiovascular.",
"vascularAgeTitle": "Edad vascular",
"vascularAgeBody": "La edad vascular estima qué edad aparentan tus arterias en comparación con tu edad real, según la rigidez arterial. Una edad vascular por debajo de la real sugiere vasos sanguíneos más sanos.",
"environmentalAudioTitle": "Niveles de sonido ambiental",
"environmentalAudioBody": "Los niveles de sonido ambiental registran el volumen del entorno en decibelios. La exposición prolongada por encima de unos 70 dB puede dañar la audición con el tiempo, así que los promedios diarios más bajos son más seguros.",
"headphoneAudioTitle": "Niveles de audio con auriculares",
"headphoneAudioBody": "Los niveles de audio con auriculares registran en decibelios el volumen al que escuchas con auriculares. Bajar el volumen y hacer pausas protege tu audición a largo plazo.",
"audioEventsTitle": "Eventos de sonido fuerte",
"audioEventsBody": "Los eventos de sonido fuerte señalan momentos en que el ruido a tu alrededor alcanzó un nivel que puede dañar la audición con suficiente exposición. Los eventos frecuentes son una señal para buscar un entorno más silencioso o protección auditiva.",
"daylightTitle": "Tiempo a la luz del día",
"daylightBody": "El tiempo a la luz del día es cuánto pasas bajo luz exterior cada día. La luz diurna regular favorece un ritmo sueño-vigilia y un estado de ánimo saludables, y el tiempo al aire libre se asocia con la salud ocular.",
"bloodGlucoseTitle": "Glucosa en sangre",
"bloodGlucoseBody": "La glucosa en sangre es la cantidad de azúcar en tu sangre, la principal fuente de energía del cuerpo. Sube tras comer y se estabiliza entre comidas; valores que se mantienen altos con el tiempo pueden indicar diabetes o prediabetes.",
"skinTemperatureTitle": "Temperatura de la piel",
"skinTemperatureBody": "La temperatura de la piel aquí es el pequeño cambio nocturno de la temperatura de tu muñeca respecto a tu propio valor de referencia, no una medición absoluta de fiebre. Las variaciones pueden seguir el sueño, el entorno, una enfermedad o la fase del ciclo menstrual."
}
},
"sleep": {
"title": "Sueño",
+84 -1
View File
@@ -1800,7 +1800,90 @@
"medikamenteEmptyDescription": "Les insights d’observance ont besoin d’au moins un médicament enregistré.",
"medikamenteEmptyAction": "Ouvrir les médicaments",
"therapyTimelineDescription": "Journal chronologique de la thérapie GLP-1 — changements de dose, injections, stock, effets secondaires.",
"bmiDescription": "IMC dérivé de ton poids et de ta taille, avec évaluation."
"bmiDescription": "IMC dérivé de ton poids et de ta taille, avec évaluation.",
"target": {
"range": "Cible : {min}–{max} {unit}",
"bpRange": "Cible : {sysMin}–{sysMax} / {diaMin}–{diaMax} {unit}",
"inTargetShare": "{pct}% des jours enregistrés dans la cible",
"adjustLink": "Ajuster la plage cible"
},
"explainer": {
"trigger": "Qu'est-ce que {title} ?",
"bloodPressureTitle": "Tension artérielle",
"bloodPressureBody": "La tension artérielle est la force exercée par le sang sur les parois des artères, exprimée en systolique sur diastolique (le pic lors d'un battement sur la valeur de repos entre les battements). Des valeurs durablement élevées sollicitent le cœur et les vaisseaux à la longue.",
"pulseTitle": "Pouls",
"pulseBody": "Le pouls correspond au nombre de battements du cœur par minute. Une mesure ponctuelle reflète ce que vous faisiez à l'instant : au repos, il est généralement de 60 à 100 battements par minute et augmente naturellement avec le mouvement, le stress ou la caféine.",
"weightTitle": "Poids",
"weightBody": "Le poids corporel est l'un des signaux les plus simples de l'équilibre énergétique à long terme et de la santé globale. La tendance sur plusieurs semaines compte bien plus qu'une mesure isolée, qui varie avec l'hydratation, l'alimentation et l'heure de la journée.",
"bmiTitle": "Indice de masse corporelle (IMC)",
"bmiBody": "L'IMC correspond à votre poids divisé par votre taille au carré, un seul chiffre qui vous situe dans une large catégorie de poids. C'est un dépistage de population, pas un diagnostic, et il ne distingue pas le muscle de la graisse.",
"sleepTitle": "Sommeil",
"sleepBody": "La durée du sommeil est le temps réellement dormi au cours de la nuit, réparti entre sommeil léger, profond et paradoxal. La plupart des adultes ont besoin d'environ sept à neuf heures ; une quantité et une qualité régulières soutiennent la récupération, l'humeur et le métabolisme.",
"moodTitle": "Humeur",
"moodBody": "Le suivi de l'humeur consigne ce que vous ressentez au fil du temps sur une échelle simple. Le noter régulièrement révèle des tendances — comment le sommeil, l'activité ou les médicaments s'alignent sur votre ressenti — qu'une seule journée ne montre jamais.",
"medicationsTitle": "Observance du traitement",
"medicationsBody": "L'observance mesure la fiabilité avec laquelle vous prenez votre traitement comme prescrit, ici sous forme de la part des doses attendues que vous avez enregistrées. Une prise régulière est souvent ce qui permet à un traitement d'agir comme prévu.",
"restingHrTitle": "Fréquence cardiaque au repos",
"restingHrBody": "La fréquence cardiaque au repos est votre rythme cardiaque lorsque vous êtes totalement au repos, généralement mesuré la nuit. Une fréquence plus basse indique souvent une meilleure forme cardiovasculaire, et une tendance à la hausse peut être un signe précoce de stress ou de maladie.",
"hrvTitle": "Variabilité de la fréquence cardiaque (VFC)",
"hrvBody": "La VFC est la petite variation du temps entre des battements consécutifs. Une variabilité plus élevée reflète généralement un système nerveux bien récupéré et adaptable, tandis qu'une baisse durable peut signaler de la fatigue, du stress ou une récupération insuffisante.",
"oxygenSaturationTitle": "Saturation en oxygène (SpO₂)",
"oxygenSaturationBody": "La SpO₂ est le pourcentage de globules rouges transportant de l'oxygène. Des valeurs de 95 à 100 % sont normales au repos ; des valeurs qui restent sous 90 % méritent attention, bien que de brèves baisses nocturnes soient fréquentes.",
"bodyTemperatureTitle": "Température corporelle",
"bodyTemperatureBody": "La température corporelle reflète votre état thermique central, normalement autour de 37 °C avec de petites variations quotidiennes. Une hausse durable peut indiquer une infection, tandis que les mesures nocturnes au poignet suivent surtout des variations relatives plutôt qu'une valeur clinique.",
"activeEnergyTitle": "Énergie active",
"activeEnergyBody": "L'énergie active correspond aux calories que vous brûlez par le mouvement, en plus de la dépense de base au repos. C'est une mesure directe du niveau d'activité physique de votre journée.",
"workoutsTitle": "Entraînements",
"workoutsBody": "Les entraînements sont les séances d'exercice structurées que vos appareils enregistrent, avec le type, la durée et l'énergie. Les suivre montre comment un entraînement régulier s'accumule au fil des semaines et comment il se relie à vos autres signaux de santé.",
"respiratoryRateTitle": "Fréquence respiratoire",
"respiratoryRateBody": "La fréquence respiratoire est le nombre de respirations que vous prenez par minute. Au repos, la plupart des adultes respirent environ douze à vingt fois par minute ; une hausse durable peut accompagner une maladie, un effort ou du stress.",
"bodyWaterTitle": "Eau corporelle totale",
"bodyWaterBody": "L'eau corporelle totale est la part de votre poids constituée de liquide, à l'intérieur et autour des cellules. Elle varie avec l'hydratation, l'alimentation et l'activité, si bien que la tendance sur plusieurs semaines en dit plus qu'une seule mesure.",
"boneMassTitle": "Masse osseuse",
"boneMassBody": "La masse osseuse est le poids estimé des minéraux de votre squelette. Les estimations sur balance sont approximatives, mais une tendance stable ou en hausse soutient la solidité osseuse à long terme.",
"fatFreeMassTitle": "Masse non grasse",
"fatFreeMassBody": "La masse non grasse est tout ce qui, dans votre corps, n'est pas de la graisse : muscle, os, organes et eau. La maintenir ou la développer tout en perdant du poids signale en général une composition corporelle plus saine.",
"fatMassTitle": "Masse grasse",
"fatMassBody": "La masse grasse est le poids total de la graisse de votre corps. Une part de graisse est indispensable aux hormones et au stockage d'énergie, mais un excès — surtout au niveau de l'abdomen — augmente le risque pour la santé à long terme.",
"muscleMassTitle": "Masse musculaire",
"muscleMassBody": "La masse musculaire est le poids estimé des muscles de votre corps. Plus de muscle soutient la force, le métabolisme et la mobilité, et demande généralement un effort actif pour être préservé avec l'âge.",
"visceralFatTitle": "Graisse viscérale",
"visceralFatBody": "La graisse viscérale est la graisse stockée au plus profond de l'abdomen, autour des organes. Contrairement à la graisse sous la peau, un niveau élevé est étroitement lié aux maladies cardiaques et au diabète de type 2 : plus elle est basse, mieux c'est.",
"leanBodyMassTitle": "Masse maigre",
"leanBodyMassBody": "La masse maigre est votre poids total moins la graisse corporelle : muscle, os, organes et eau réunis. La suivre en parallèle du poids montre si les variations viennent du muscle ou de la graisse.",
"flightsClimbedTitle": "Étages montés",
"flightsClimbedBody": "Les étages montés comptent les niveaux que vous gravissez chaque jour, un étage correspondant à environ trois mètres de dénivelé. C'est un indicateur simple de l'activité du quotidien et de l'effort des jambes.",
"walkingDistanceTitle": "Distance à pied et en course",
"walkingDistanceBody": "La distance à pied et en course est le trajet que vous parcourez à pied chaque jour. Elle complète le nombre de pas en reflétant la distance parcourue, pas seulement la fréquence des pas.",
"walkingSteadinessTitle": "Stabilité de la marche",
"walkingSteadinessBody": "La stabilité de la marche estime à quel point votre démarche est stable et équilibrée, classée d'acceptable à faible. Un niveau bas indique un risque de chute plus élevé et mérite d'être évoqué avec un professionnel de santé.",
"walkingHeartRateTitle": "Fréquence cardiaque à la marche",
"walkingHeartRateBody": "La fréquence cardiaque à la marche est votre pouls moyen lors d'une marche régulière. Une valeur plus basse au fil du temps reflète souvent une meilleure forme cardiovasculaire pour le même effort.",
"walkingAsymmetryTitle": "Asymétrie de la marche",
"walkingAsymmetryBody": "L'asymétrie de la marche est la part du temps où vos pas sont inégaux entre la jambe gauche et la droite. Un pourcentage plus élevé peut signaler une douleur, une blessure ou un trouble de la marche à examiner.",
"doubleSupportTimeTitle": "Temps de double appui",
"doubleSupportTimeBody": "Le temps de double appui est la part de chaque cycle de marche où les deux pieds touchent le sol en même temps. Un pourcentage plus élevé traduit en général une démarche plus prudente et moins stable.",
"stepLengthTitle": "Longueur de pas",
"stepLengthBody": "La longueur de pas est la distance moyenne d'une foulée à la suivante. Un pas plus court au fil du temps peut traduire de la fatigue, de la prudence ou un changement de mobilité.",
"walkingSpeedTitle": "Vitesse de marche",
"walkingSpeedBody": "La vitesse de marche est la rapidité de votre déplacement sur terrain plat. C'est un marqueur bien étudié de la santé générale et de la mobilité, et une allure stable ou en hausse est bon signe.",
"pulseWaveVelocityTitle": "Vitesse de l'onde de pouls",
"pulseWaveVelocityBody": "La vitesse de l'onde de pouls mesure la rapidité avec laquelle l'onde de pression de chaque battement parcourt vos artères. Une onde plus rapide indique des artères plus rigides, liées à un risque cardiovasculaire accru.",
"vascularAgeTitle": "Âge vasculaire",
"vascularAgeBody": "L'âge vasculaire estime, d'après la rigidité artérielle, l'âge apparent de vos artères par rapport à votre âge réel. Un âge vasculaire inférieur à votre âge réel suggère des vaisseaux sanguins en meilleure santé.",
"environmentalAudioTitle": "Niveaux sonores ambiants",
"environmentalAudioBody": "Les niveaux sonores ambiants suivent le volume de votre environnement en décibels. Une exposition prolongée au-delà d'environ 70 dB peut endommager l'audition avec le temps : des moyennes journalières plus basses sont plus sûres.",
"headphoneAudioTitle": "Niveaux audio au casque",
"headphoneAudioBody": "Les niveaux audio au casque suivent, en décibels, le volume auquel vous écoutez avec des écouteurs ou un casque. Baisser le volume et faire des pauses protège votre audition sur le long terme.",
"audioEventsTitle": "Événements sonores forts",
"audioEventsBody": "Les événements sonores forts signalent les moments où le bruit autour de vous a atteint un niveau pouvant nuire à l'audition en cas d'exposition suffisante. Des événements fréquents invitent à rechercher un environnement plus calme ou une protection auditive.",
"daylightTitle": "Temps à la lumière du jour",
"daylightBody": "Le temps à la lumière du jour correspond à la durée passée en lumière extérieure chaque jour. Une lumière naturelle régulière soutient un rythme veille-sommeil et une humeur sains, et le temps passé dehors est lié à la santé oculaire.",
"bloodGlucoseTitle": "Glycémie",
"bloodGlucoseBody": "La glycémie est la quantité de sucre dans votre sang, principale source d'énergie de l'organisme. Elle augmente après les repas et se stabilise entre eux ; des valeurs durablement élevées peuvent signaler un diabète ou un prédiabète.",
"skinTemperatureTitle": "Température cutanée",
"skinTemperatureBody": "La température cutanée correspond ici à la petite variation nocturne de la température de votre poignet par rapport à votre propre référence, et non à une mesure absolue de fièvre. Les variations peuvent suivre le sommeil, l'environnement, une maladie ou la phase du cycle menstruel."
}
},
"sleep": {
"title": "Sommeil",
+84 -1
View File
@@ -1800,7 +1800,90 @@
"medikamenteEmptyDescription": "Gli insights di aderenza richiedono almeno un farmaco registrato.",
"medikamenteEmptyAction": "Apri farmaci",
"therapyTimelineDescription": "Protocollo cronologico della terapia GLP-1 — cambi di dose, iniezioni, scorte, effetti collaterali.",
"bmiDescription": "BMI derivato dal tuo peso e dalla tua altezza, con valutazione."
"bmiDescription": "BMI derivato dal tuo peso e dalla tua altezza, con valutazione.",
"target": {
"range": "Obiettivo: {min}–{max} {unit}",
"bpRange": "Obiettivo: {sysMin}–{sysMax} / {diaMin}–{diaMax} {unit}",
"inTargetShare": "{pct}% dei giorni registrati nell'obiettivo",
"adjustLink": "Modifica l'intervallo obiettivo"
},
"explainer": {
"trigger": "Che cos'è {title}?",
"bloodPressureTitle": "Pressione arteriosa",
"bloodPressureBody": "La pressione arteriosa è la forza che il sangue esercita sulle pareti delle arterie, indicata come sistolica su diastolica (il picco durante un battito sul valore a riposo tra i battiti). Valori costantemente alti affaticano il cuore e i vasi nel tempo.",
"pulseTitle": "Polso",
"pulseBody": "Il polso indica quante volte il cuore batte al minuto. Una misurazione singola riflette ciò che stavi facendo in quel momento: a riposo è di solito tra 60 e 100 battiti al minuto e aumenta naturalmente con il movimento, lo stress o la caffeina.",
"weightTitle": "Peso",
"weightBody": "Il peso corporeo è uno dei segnali più semplici dell'equilibrio energetico a lungo termine e della salute generale. L'andamento nelle settimane conta molto più di una singola misurazione, che varia con l'idratazione, il cibo e l'ora del giorno.",
"bmiTitle": "Indice di massa corporea (IMC)",
"bmiBody": "L'IMC è il peso diviso per l'altezza al quadrato, un singolo numero che ti colloca in un'ampia categoria di peso. È uno screening di popolazione, non una diagnosi, e non distingue il muscolo dal grasso.",
"sleepTitle": "Sonno",
"sleepBody": "La durata del sonno è quanto hai effettivamente dormito nella notte, suddivisa in fasi leggera, profonda e REM. La maggior parte degli adulti necessita di circa sette-nove ore; quantità e qualità costanti favoriscono recupero, umore e metabolismo.",
"moodTitle": "Umore",
"moodBody": "Il monitoraggio dell'umore registra come ti senti nel tempo su una scala semplice. Annotarlo con regolarità rivela schemi — come sonno, attività o farmaci si allineano a come ti senti — che un singolo giorno non mostra mai.",
"medicationsTitle": "Aderenza alla terapia",
"medicationsBody": "L'aderenza misura quanto regolarmente assumi i farmaci come prescritto, qui come quota delle dosi previste che hai registrato. Un'assunzione costante è spesso ciò che fa funzionare una terapia come previsto.",
"restingHrTitle": "Frequenza cardiaca a riposo",
"restingHrBody": "La frequenza cardiaca a riposo è il battito quando sei completamente a riposo, di solito misurato durante la notte. Una frequenza più bassa indica in genere una migliore forma cardiovascolare, mentre un andamento in aumento può essere un segnale precoce di stress o malattia.",
"hrvTitle": "Variabilità della frequenza cardiaca (HRV)",
"hrvBody": "L'HRV è la piccola variazione del tempo tra battiti consecutivi. Una variabilità più alta riflette di solito un sistema nervoso ben recuperato e adattabile, mentre un calo prolungato può segnalare affaticamento, stress o recupero insufficiente.",
"oxygenSaturationTitle": "Saturazione di ossigeno (SpO₂)",
"oxygenSaturationBody": "La SpO₂ è la percentuale di globuli rossi che trasportano ossigeno. Valori tra 95 e 100% sono normali a riposo; valori che restano sotto il 90% meritano attenzione, anche se brevi cali notturni sono comuni.",
"bodyTemperatureTitle": "Temperatura corporea",
"bodyTemperatureBody": "La temperatura corporea riflette il tuo stato termico centrale, normalmente intorno ai 37 °C con piccole oscillazioni giornaliere. Un aumento prolungato può indicare un'infezione, mentre le misurazioni notturne al polso seguono soprattutto variazioni relative, non un valore clinico.",
"activeEnergyTitle": "Energia attiva",
"activeEnergyBody": "L'energia attiva sono le calorie che bruci con il movimento, oltre al consumo di base a riposo. È una misura diretta di quanto è stata fisicamente attiva la tua giornata.",
"workoutsTitle": "Allenamenti",
"workoutsBody": "Gli allenamenti sono le sessioni di esercizio strutturato che i tuoi dispositivi registrano, con tipo, durata ed energia. Tracciarli mostra come l'allenamento regolare si accumula nel corso delle settimane e come si collega agli altri segnali di salute.",
"respiratoryRateTitle": "Frequenza respiratoria",
"respiratoryRateBody": "La frequenza respiratoria è il numero di respiri che fai al minuto. A riposo la maggior parte degli adulti respira circa dodici-venti volte al minuto; un aumento prolungato può accompagnare malattia, sforzo o stress.",
"bodyWaterTitle": "Acqua corporea totale",
"bodyWaterBody": "L'acqua corporea totale è la quota del tuo peso costituita da liquido, dentro e intorno alle cellule. Varia con l'idratazione, l'alimentazione e l'attività, quindi l'andamento nelle settimane dice più di una singola misura.",
"boneMassTitle": "Massa ossea",
"boneMassBody": "La massa ossea è il peso stimato dei minerali del tuo scheletro. Le stime da bilancia sono approssimative, ma un andamento stabile o in aumento sostiene la robustezza ossea a lungo termine.",
"fatFreeMassTitle": "Massa magra",
"fatFreeMassBody": "La massa magra è tutto ciò che nel corpo non è grasso: muscoli, ossa, organi e acqua. Mantenerla o aumentarla mentre si perde peso indica di solito una composizione corporea più sana.",
"fatMassTitle": "Massa grassa",
"fatMassBody": "La massa grassa è il peso totale del grasso nel corpo. Un po' di grasso è essenziale per gli ormoni e le riserve di energia, ma un eccesso — soprattutto sull'addome — aumenta il rischio per la salute a lungo termine.",
"muscleMassTitle": "Massa muscolare",
"muscleMassBody": "La massa muscolare è il peso stimato dei muscoli del corpo. Più muscolo sostiene forza, metabolismo e mobilità, e di solito richiede impegno attivo per essere mantenuto con l'età.",
"visceralFatTitle": "Grasso viscerale",
"visceralFatBody": "Il grasso viscerale è il grasso depositato in profondità nell'addome, intorno agli organi. A differenza del grasso sottocutaneo, un livello elevato è strettamente legato a malattie cardiache e diabete di tipo 2, quindi più basso è in genere meglio.",
"leanBodyMassTitle": "Massa magra corporea",
"leanBodyMassBody": "La massa magra corporea è il peso totale meno il grasso: muscoli, ossa, organi e acqua insieme. Seguirla accanto al peso mostra se i cambiamenti derivano dal muscolo o dal grasso.",
"flightsClimbedTitle": "Piani saliti",
"flightsClimbedBody": "I piani saliti contano i livelli che sali ogni giorno, dove un piano corrisponde a circa tre metri di dislivello. È un indicatore semplice dell'attività quotidiana e dello sforzo degli arti inferiori.",
"walkingDistanceTitle": "Distanza a piedi e di corsa",
"walkingDistanceBody": "La distanza a piedi e di corsa è il percorso che copri a piedi ogni giorno. Integra il conteggio dei passi mostrando quanto ti sei mosso, non solo quanto spesso hai mosso il passo.",
"walkingSteadinessTitle": "Stabilità del cammino",
"walkingSteadinessBody": "La stabilità del cammino stima quanto è stabile ed equilibrata la tua andatura, classificata da buona a bassa. Un livello basso indica una maggiore probabilità di caduta ed è opportuno parlarne con un medico.",
"walkingHeartRateTitle": "Frequenza cardiaca in camminata",
"walkingHeartRateBody": "La frequenza cardiaca in camminata è il battito medio durante una camminata costante. Un valore più basso nel tempo riflette spesso una migliore forma cardiovascolare a parità di sforzo.",
"walkingAsymmetryTitle": "Asimmetria del cammino",
"walkingAsymmetryBody": "L'asimmetria del cammino è la quota di tempo in cui i tuoi passi sono diseguali tra gamba sinistra e destra. Una percentuale più alta può indicare dolore, un infortunio o un problema di andatura da approfondire.",
"doubleSupportTimeTitle": "Tempo di doppio appoggio",
"doubleSupportTimeBody": "Il tempo di doppio appoggio è la quota di ogni ciclo del passo in cui entrambi i piedi toccano terra contemporaneamente. Una percentuale più alta indica di solito un'andatura più prudente e meno stabile.",
"stepLengthTitle": "Lunghezza del passo",
"stepLengthBody": "La lunghezza del passo è la distanza media da un appoggio del piede al successivo. Un passo più corto nel tempo può riflettere stanchezza, prudenza o un cambiamento nella mobilità.",
"walkingSpeedTitle": "Velocità del cammino",
"walkingSpeedBody": "La velocità del cammino è quanto rapidamente ti muovi in piano. È un marcatore ben studiato della salute generale e della mobilità, e un ritmo stabile o in aumento è un buon segno.",
"pulseWaveVelocityTitle": "Velocità dell'onda di polso",
"pulseWaveVelocityBody": "La velocità dell'onda di polso misura la rapidità con cui l'onda di pressione di ogni battito percorre le arterie. Un'onda più veloce indica arterie più rigide, associate a un rischio cardiovascolare più alto.",
"vascularAgeTitle": "Età vascolare",
"vascularAgeBody": "L'età vascolare stima quanto si comportano vecchie le tue arterie rispetto alla tua età reale, in base alla rigidità arteriosa. Un'età vascolare inferiore a quella reale suggerisce vasi sanguigni più sani.",
"environmentalAudioTitle": "Livelli sonori ambientali",
"environmentalAudioBody": "I livelli sonori ambientali rilevano il volume dell'ambiente intorno a te in decibel. Un'esposizione prolungata sopra i 70 dB circa può danneggiare l'udito nel tempo, quindi medie giornaliere più basse sono più sicure.",
"headphoneAudioTitle": "Livelli audio in cuffia",
"headphoneAudioBody": "I livelli audio in cuffia rilevano in decibel quanto forte ascolti con auricolari o cuffie. Tenere basso il volume e fare pause protegge l'udito nel lungo periodo.",
"audioEventsTitle": "Eventi sonori intensi",
"audioEventsBody": "Gli eventi sonori intensi segnalano i momenti in cui il rumore intorno a te ha raggiunto un livello che può danneggiare l'udito con esposizione sufficiente. Eventi frequenti sono un invito a cercare un ambiente più silenzioso o protezioni acustiche.",
"daylightTitle": "Tempo alla luce del giorno",
"daylightBody": "Il tempo alla luce del giorno è quanto trascorri sotto la luce esterna ogni giorno. La luce diurna regolare sostiene un sano ritmo sonno-veglia e l'umore, e il tempo all'aperto è collegato alla salute degli occhi.",
"bloodGlucoseTitle": "Glicemia",
"bloodGlucoseBody": "La glicemia è la quantità di zucchero nel sangue, la principale fonte di energia del corpo. Sale dopo i pasti e si stabilizza tra un pasto e l'altro; valori che restano alti nel tempo possono indicare diabete o prediabete.",
"skinTemperatureTitle": "Temperatura cutanea",
"skinTemperatureBody": "La temperatura cutanea qui è la piccola variazione notturna della temperatura del polso rispetto al tuo valore di riferimento, non una misura assoluta della febbre. Le variazioni possono seguire sonno, ambiente, malattia o fase del ciclo mestruale."
}
},
"sleep": {
"title": "Sonno",
+84 -1
View File
@@ -1800,7 +1800,90 @@
"medikamenteEmptyDescription": "Insights przestrzegania wymagają przynajmniej jednego zarejestrowanego leku.",
"medikamenteEmptyAction": "Otwórz leki",
"therapyTimelineDescription": "Chronologiczny protokół terapii GLP-1 — zmiany dawki, iniekcje, stan, działania niepożądane.",
"bmiDescription": "BMI obliczone z twojej wagi i wzrostu, z oceną."
"bmiDescription": "BMI obliczone z twojej wagi i wzrostu, z oceną.",
"target": {
"range": "Cel: {min}–{max} {unit}",
"bpRange": "Cel: {sysMin}–{sysMax} / {diaMin}–{diaMax} {unit}",
"inTargetShare": "{pct}% zarejestrowanych dni w zakresie celu",
"adjustLink": "Dostosuj zakres docelowy"
},
"explainer": {
"trigger": "Czym jest {title}?",
"bloodPressureTitle": "Ciśnienie krwi",
"bloodPressureBody": "Ciśnienie krwi to siła, z jaką krew napiera na ściany tętnic, zapisywana jako skurczowe do rozkurczowego (szczyt podczas uderzenia serca do wartości spoczynkowej między uderzeniami). Stale wysokie wartości z czasem obciążają serce i naczynia.",
"pulseTitle": "Tętno",
"pulseBody": "Tętno to liczba uderzeń serca na minutę. Pojedynczy pomiar odzwierciedla to, co robiłeś w danej chwili — w spoczynku wynosi zwykle 60–100 uderzeń na minutę i naturalnie rośnie przy ruchu, stresie lub kofeinie.",
"weightTitle": "Waga",
"weightBody": "Masa ciała to jeden z najprostszych sygnałów długoterminowego bilansu energetycznego i ogólnego zdrowia. Trend na przestrzeni tygodni liczy się znacznie bardziej niż pojedynczy odczyt, który zmienia się wraz z nawodnieniem, jedzeniem i porą dnia.",
"bmiTitle": "Wskaźnik masy ciała (BMI)",
"bmiBody": "BMI to waga podzielona przez wzrost do kwadratu — jedna liczba przypisująca Cię do szerokiej kategorii wagowej. To badanie przesiewowe dla populacji, a nie diagnoza, i nie odróżnia mięśni od tłuszczu.",
"sleepTitle": "Sen",
"sleepBody": "Długość snu to czas, który faktycznie przespałeś w nocy, podzielony na fazy lekką, głęboką i REM. Większość dorosłych potrzebuje około siedmiu do dziewięciu godzin; stała ilość i jakość wspierają regenerację, nastrój i metabolizm.",
"moodTitle": "Nastrój",
"moodBody": "Śledzenie nastroju rejestruje Twoje samopoczucie w czasie na prostej skali. Regularne zapisywanie ujawnia wzorce — jak sen, aktywność czy leki łączą się z samopoczuciem — których pojedynczy dzień nigdy nie pokaże.",
"medicationsTitle": "Przestrzeganie zaleceń lekowych",
"medicationsBody": "Przestrzeganie zaleceń to rzetelność przyjmowania leków zgodnie z zaleceniami, pokazana tu jako odsetek oczekiwanych dawek, które zarejestrowałeś. Regularne przyjmowanie często decyduje o tym, czy terapia działa zgodnie z zamierzeniem.",
"restingHrTitle": "Tętno spoczynkowe",
"restingHrBody": "Tętno spoczynkowe to częstość uderzeń serca w pełnym spoczynku, zwykle mierzona w nocy. Niższe tętno zwykle wskazuje na lepszą wydolność krążeniową, a rosnący trend może być wczesnym sygnałem stresu lub choroby.",
"hrvTitle": "Zmienność rytmu serca (HRV)",
"hrvBody": "HRV to drobna zmienność czasu między kolejnymi uderzeniami serca. Wyższa zmienność zwykle odzwierciedla dobrze zregenerowany, elastyczny układ nerwowy, a utrzymujący się spadek może sygnalizować zmęczenie, stres lub niewystarczającą regenerację.",
"oxygenSaturationTitle": "Saturacja tlenem (SpO₂)",
"oxygenSaturationBody": "SpO₂ to odsetek czerwonych krwinek przenoszących tlen. Wartości 95–100% są normalne w spoczynku; wartości utrzymujące się poniżej 90% wymagają uwagi, choć krótkie nocne spadki są częste.",
"bodyTemperatureTitle": "Temperatura ciała",
"bodyTemperatureBody": "Temperatura ciała odzwierciedla stan cieplny rdzenia, zwykle około 37 °C z niewielkimi dobowymi wahaniami. Utrzymujący się wzrost może wskazywać na infekcję, podczas gdy nocne pomiary na nadgarstku śledzą głównie zmiany względne, a nie wartość kliniczną.",
"activeEnergyTitle": "Energia aktywna",
"activeEnergyBody": "Energia aktywna to kalorie spalane przez ruch, ponad podstawowy wydatek w spoczynku. To bezpośrednia miara tego, jak aktywny fizycznie był Twój dzień.",
"workoutsTitle": "Treningi",
"workoutsBody": "Treningi to ustrukturyzowane sesje ćwiczeń rejestrowane przez Twoje urządzenia, wraz z typem, czasem trwania i energią. Ich śledzenie pokazuje, jak regularny trening kumuluje się przez tygodnie i jak wiąże się z innymi sygnałami zdrowotnymi.",
"respiratoryRateTitle": "Częstość oddechów",
"respiratoryRateBody": "Częstość oddechów to liczba wdechów na minutę. W spoczynku większość dorosłych oddycha około dwunastu do dwudziestu razy na minutę; utrzymujący się wzrost może towarzyszyć chorobie, wysiłkowi lub stresowi.",
"bodyWaterTitle": "Całkowita woda w organizmie",
"bodyWaterBody": "Całkowita woda w organizmie to udział płynów w masie ciała, wewnątrz i wokół komórek. Zmienia się wraz z nawodnieniem, posiłkami i aktywnością, więc trend w ciągu tygodni mówi więcej niż pojedynczy pomiar.",
"boneMassTitle": "Masa kostna",
"boneMassBody": "Masa kostna to szacowana waga minerałów w szkielecie. Pomiary z wagi są przybliżone, ale stabilny lub rosnący trend sprzyja długotrwałej wytrzymałości kości.",
"fatFreeMassTitle": "Masa beztłuszczowa",
"fatFreeMassBody": "Masa beztłuszczowa to wszystko w ciele, co nie jest tłuszczem — mięśnie, kości, narządy i woda. Utrzymanie lub jej budowanie przy spadku wagi zwykle wskazuje na zdrowszy skład ciała.",
"fatMassTitle": "Masa tłuszczowa",
"fatMassBody": "Masa tłuszczowa to łączna waga tłuszczu w ciele. Pewna ilość tłuszczu jest niezbędna dla hormonów i magazynowania energii, ale jego nadmiar — zwłaszcza w okolicy brzucha — zwiększa długoterminowe ryzyko zdrowotne.",
"muscleMassTitle": "Masa mięśniowa",
"muscleMassBody": "Masa mięśniowa to szacowana waga mięśni w ciele. Więcej mięśni wspiera siłę, metabolizm i sprawność, a ich utrzymanie z wiekiem zwykle wymaga aktywnego wysiłku.",
"visceralFatTitle": "Tłuszcz trzewny",
"visceralFatBody": "Tłuszcz trzewny to tłuszcz gromadzony głęboko w jamie brzusznej wokół narządów. W odróżnieniu od tłuszczu podskórnego jego wysoki poziom jest ściśle powiązany z chorobami serca i cukrzycą typu 2, więc im niżej, tym lepiej.",
"leanBodyMassTitle": "Beztłuszczowa masa ciała",
"leanBodyMassBody": "Beztłuszczowa masa ciała to całkowita waga pomniejszona o tłuszcz — mięśnie, kości, narządy i woda razem. Śledzenie jej obok wagi pokazuje, czy zmiany wynikają z mięśni, czy z tłuszczu.",
"flightsClimbedTitle": "Pokonane piętra",
"flightsClimbedBody": "Pokonane piętra liczą kondygnacje, które pokonujesz w górę każdego dnia, gdzie jedno piętro to około trzech metrów wzniesienia. To prosty wskaźnik codziennej aktywności i wysiłku nóg.",
"walkingDistanceTitle": "Dystans marszu i biegu",
"walkingDistanceBody": "Dystans marszu i biegu to droga, jaką pokonujesz pieszo każdego dnia. Uzupełnia liczbę kroków, pokazując, jak daleko się poruszałeś, a nie tylko jak często stawiałeś kroki.",
"walkingSteadinessTitle": "Stabilność chodu",
"walkingSteadinessBody": "Stabilność chodu szacuje, jak stabilny i zrównoważony jest twój sposób chodzenia, w skali od dobrej do niskiej. Niski poziom wskazuje na większe ryzyko upadku i warto omówić go z lekarzem.",
"walkingHeartRateTitle": "Tętno podczas chodzenia",
"walkingHeartRateBody": "Tętno podczas chodzenia to średni puls w trakcie równego marszu. Niższa wartość w czasie często odzwierciedla poprawę wydolności krążeniowej przy tym samym wysiłku.",
"walkingAsymmetryTitle": "Asymetria chodu",
"walkingAsymmetryBody": "Asymetria chodu to odsetek czasu, w którym kroki są nierówne między lewą a prawą nogą. Wyższy procent może wskazywać na ból, kontuzję lub problem z chodem wart sprawdzenia.",
"doubleSupportTimeTitle": "Czas podwójnego podparcia",
"doubleSupportTimeBody": "Czas podwójnego podparcia to udział każdego cyklu chodu, w którym obie stopy dotykają ziemi jednocześnie. Wyższy procent zwykle oznacza ostrożniejszy, mniej stabilny chód.",
"stepLengthTitle": "Długość kroku",
"stepLengthBody": "Długość kroku to średnia odległość od jednego postawienia stopy do następnego. Krótszy krok w czasie może odzwierciedlać zmęczenie, ostrożność lub zmianę sprawności ruchowej.",
"walkingSpeedTitle": "Prędkość chodu",
"walkingSpeedBody": "Prędkość chodu to tempo poruszania się po płaskim terenie. To dobrze zbadany wskaźnik ogólnego zdrowia i sprawności, a stałe lub rosnące tempo to dobry znak.",
"pulseWaveVelocityTitle": "Prędkość fali tętna",
"pulseWaveVelocityBody": "Prędkość fali tętna mierzy, jak szybko fala ciśnienia z każdego uderzenia serca przemieszcza się przez tętnice. Szybsza fala wskazuje na sztywniejsze tętnice, co wiąże się z wyższym ryzykiem sercowo-naczyniowym.",
"vascularAgeTitle": "Wiek naczyniowy",
"vascularAgeBody": "Wiek naczyniowy szacuje, jak stare zachowują się twoje tętnice w porównaniu z rzeczywistym wiekiem, na podstawie sztywności tętnic. Wiek naczyniowy niższy od rzeczywistego sugeruje zdrowsze naczynia krwionośne.",
"environmentalAudioTitle": "Poziom dźwięku otoczenia",
"environmentalAudioBody": "Poziom dźwięku otoczenia śledzi głośność otoczenia w decybelach. Długotrwała ekspozycja powyżej około 70 dB może z czasem uszkodzić słuch, więc niższe średnie dzienne są bezpieczniejsze.",
"headphoneAudioTitle": "Poziom dźwięku w słuchawkach",
"headphoneAudioBody": "Poziom dźwięku w słuchawkach śledzi w decybelach, jak głośno słuchasz przez słuchawki. Ściszanie głośności i robienie przerw chroni słuch w dłuższej perspektywie.",
"audioEventsTitle": "Zdarzenia głośnego dźwięku",
"audioEventsBody": "Zdarzenia głośnego dźwięku oznaczają momenty, gdy hałas wokół ciebie osiągnął poziom mogący przy wystarczającej ekspozycji uszkodzić słuch. Częste zdarzenia to sygnał, by poszukać cichszego otoczenia lub ochrony słuchu.",
"daylightTitle": "Czas w świetle dziennym",
"daylightBody": "Czas w świetle dziennym to ilość czasu spędzanego w świetle na zewnątrz każdego dnia. Regularne światło dzienne wspiera zdrowy rytm snu i czuwania oraz nastrój, a czas na świeżym powietrzu wiąże się ze zdrowiem oczu.",
"bloodGlucoseTitle": "Poziom glukozy we krwi",
"bloodGlucoseBody": "Poziom glukozy we krwi to ilość cukru we krwi, głównego źródła energii organizmu. Rośnie po jedzeniu i ustala się między posiłkami; wartości utrzymujące się wysoko mogą sygnalizować cukrzycę lub stan przedcukrzycowy.",
"skinTemperatureTitle": "Temperatura skóry",
"skinTemperatureBody": "Temperatura skóry to tutaj niewielka nocna zmiana temperatury nadgarstka względem twojej własnej wartości bazowej, a nie bezwzględny pomiar gorączki. Zmiany mogą odzwierciedlać sen, otoczenie, chorobę lub fazę cyklu menstruacyjnego."
}
},
"sleep": {
"title": "Sen",
+59
View File
@@ -53,6 +53,65 @@ const nextConfig: NextConfig = {
// from memory instead of paying a full reload. See
// `src/lib/http/cache-headers.ts` for the typed constant reused by
// route handlers that opt into the same posture.
// v1.8.0 — the routed Insights sub-pages migrated from German to
// English slugs (`/insights/blutdruck` → `/insights/blood-pressure`,
// …). Every legacy German URL 301-redirects to its English target so
// bookmarks, the PWA's cached navigation, and any external link keep
// resolving. The redirect set is exhaustive and matches the rename
// table in `docs/adr/0001-insights-naming-convention.md`; the slug
// registry itself lives in `src/lib/insights/sub-page-metric.ts`.
// `bmi`, `hrv`, and `workouts` were already English and need no entry.
async redirects() {
const insightsSlugRenames: Array<[string, string]> = [
["blutdruck", "blood-pressure"],
["puls", "pulse"],
["sauerstoff", "oxygen"],
["koerpertemperatur", "body-temperature"],
["atemfrequenz", "respiratory-rate"],
["gewicht", "weight"],
["koerperwasser", "body-water"],
["knochenmasse", "bone-mass"],
["fettfreie-masse", "fat-free-mass"],
["fettmasse", "fat-mass"],
["muskelmasse", "muscle-mass"],
["viszeralfett", "visceral-fat"],
["magermasse", "lean-body-mass"],
["aktive-energie", "active-energy"],
["stockwerke", "flights-climbed"],
["gehstrecke", "walking-distance"],
["gangstabilitaet", "walking-steadiness"],
["gehpuls", "walking-heart-rate"],
["gangasymmetrie", "walking-asymmetry"],
["doppelstandphase", "double-support-time"],
["schrittlaenge", "step-length"],
["gehgeschwindigkeit", "walking-speed"],
["schlaf", "sleep"],
["ruhepuls", "resting-pulse"],
["pulswellengeschwindigkeit", "pulse-wave-velocity"],
["gefaessalter", "vascular-age"],
["laermbelastung", "environmental-audio"],
["kopfhoererpegel", "headphone-audio"],
["laermereignisse", "audio-events"],
["tageslicht", "daylight"],
["blutzucker", "blood-glucose"],
["hauttemperatur", "skin-temperature"],
["stimmung", "mood"],
["medikamente", "medications"],
];
return insightsSlugRenames.flatMap(([from, to]) => [
{
source: `/insights/${from}`,
destination: `/insights/${to}`,
permanent: true,
},
// Preserve any nested path (e.g. a future `/insights/sleep/2026-05-31`).
{
source: `/insights/${from}/:path*`,
destination: `/insights/${to}/:path*`,
permanent: true,
},
]);
},
async headers() {
return [
{
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.7.3",
"version": "1.8.0",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "AGPL-3.0-only",
"homepage": "https://healthlog.dev",
+1 -1
View File
@@ -4,7 +4,7 @@
* Returns a chronological merge of every GLP-1-relevant event the user
* has logged: dose changes, injections (with site if recorded),
* inventory events, and side-effect-tagged mood entries from the
* trailing 90 days. Used by /insights/medikamente's TherapyTimeline
* trailing 90 days. Used by /insights/medications' TherapyTimeline
* component. Web-only users without a GLP-1 medication get
* `hasGlp1: false` so the component hides cleanly.
*/
@@ -122,7 +122,7 @@ describe("GET /api/insights/layout", () => {
version: 1,
tiles: [
{ id: "overview", visible: false, order: 0 },
{ id: "blutdruck", visible: true, order: 1 },
{ id: "blood-pressure", visible: true, order: 1 },
],
},
} as never);
@@ -141,12 +141,42 @@ describe("GET /api/insights/layout", () => {
order: 0,
});
expect(body.data.tiles[1]).toEqual({
id: "blutdruck",
id: "blood-pressure",
visible: true,
order: 1,
});
expect(body.data.tiles.length).toBe(DEFAULT_INSIGHTS_LAYOUT.tiles.length);
});
it("normalises a legacy German tile id stored by a ≤ v1.7.x client to its canonical English id on read", async () => {
// A layout persisted before the v1.8.0 rename (or by an iOS client
// still speaking the German ids). The resolver must surface the
// canonical English id so the GET contract is current-build clean
// without forcing a re-PUT.
vi.mocked(prisma.user.findUnique).mockResolvedValue({
insightsLayoutJson: {
version: 1,
tiles: [
{ id: "overview", visible: true, order: 0 },
{ id: "blutdruck", visible: true, order: 1 },
{ id: "stimmung", visible: false, order: 2 },
],
},
} as never);
const res = await callGet();
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { tiles: Array<{ id: string; visible: boolean; order: number }> };
};
const ids = body.data.tiles.map((t) => t.id);
// Legacy ids collapse to their English replacement…
expect(ids).toContain("blood-pressure");
expect(ids).toContain("mood");
// …and the German originals never leak back out.
expect(ids).not.toContain("blutdruck");
expect(ids).not.toContain("stimmung");
});
});
describe("PUT /api/insights/layout — happy path", () => {
@@ -157,8 +187,8 @@ describe("PUT /api/insights/layout — happy path", () => {
version: 1,
tiles: [
{ id: "overview", visible: true, order: 0 },
{ id: "blutdruck", visible: true, order: 1 },
{ id: "puls", visible: false, order: 2 },
{ id: "blood-pressure", visible: true, order: 1 },
{ id: "pulse", visible: false, order: 2 },
],
};
const res = await callPut(makeReq(payload));
@@ -168,7 +198,7 @@ describe("PUT /api/insights/layout — happy path", () => {
};
expect(body.data.tiles[0]?.id).toBe("overview");
expect(body.data.tiles[0]?.order).toBe(0);
expect(body.data.tiles[2]?.id).toBe("puls");
expect(body.data.tiles[2]?.id).toBe("pulse");
expect(body.data.tiles[2]?.visible).toBe(false);
expect(prisma.user.update).toHaveBeenCalledTimes(1);
@@ -179,6 +209,47 @@ describe("PUT /api/insights/layout — happy path", () => {
expect(updateCall.where.id).toBe("user-1");
expect(updateCall.data.insightsLayoutJson).toBeTruthy();
});
it("accepts a body carrying legacy German tile ids and persists the canonical English ones", async () => {
// Non-breaking iOS contract: a client still sending the pre-v1.8.0
// German ids must NOT 422. The route validates the legacy ids and
// `serializeInsightsLayout` normalises them to English before the
// row persists, so the stored blob is always canonical.
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const payload = {
version: 1,
tiles: [
{ id: "overview", visible: true, order: 0 },
{ id: "blutdruck", visible: true, order: 1 },
{ id: "medikamente", visible: true, order: 2 },
],
};
const res = await callPut(makeReq(payload));
// No 422 — the legacy ids pass validation.
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { tiles: Array<{ id: string }> };
};
const returnedIds = body.data.tiles.map((t) => t.id);
expect(returnedIds).toContain("blood-pressure");
expect(returnedIds).toContain("medications");
expect(returnedIds).not.toContain("blutdruck");
expect(returnedIds).not.toContain("medikamente");
// The persisted blob is canonical too.
const updateCall = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as unknown as {
data: { insightsLayoutJson: { tiles: Array<{ id: string }> } };
};
const persistedIds = updateCall.data.insightsLayoutJson.tiles.map(
(t) => t.id,
);
expect(persistedIds).toContain("blood-pressure");
expect(persistedIds).toContain("medications");
expect(persistedIds).not.toContain("blutdruck");
expect(persistedIds).not.toContain("medikamente");
});
});
describe("PUT /api/insights/layout — 422 multi-issue envelope", () => {
+13 -6
View File
@@ -27,7 +27,7 @@ import {
resolveInsightsLayout,
serializeInsightsLayout,
DEFAULT_INSIGHTS_LAYOUT,
INSIGHTS_TILE_IDS,
ACCEPTED_INSIGHTS_TILE_IDS,
type InsightsLayout,
} from "@/lib/insights-layout";
import { Prisma } from "@/generated/prisma/client";
@@ -38,11 +38,18 @@ import { redactSensitiveFields } from "@/lib/observability/redact-payload";
import { shouldEmitAuditRow } from "@/lib/audit-dedup";
import type { NextRequest } from "next/server";
// Derive the enum from `INSIGHTS_TILE_IDS` so the schema + the default
// layout cannot drift — the same root-cause class that produced the
// v1.4.16 A5 silent-422 on dashboard widgets when `achievements` was
// added to the layout but not to the validation enum.
const tileIdEnum = z.enum(INSIGHTS_TILE_IDS);
// Derive the enum from `ACCEPTED_INSIGHTS_TILE_IDS` so the schema + the
// default layout cannot drift — the same root-cause class that produced
// the v1.4.16 A5 silent-422 on dashboard widgets when `achievements`
// was added to the layout but not to the validation enum.
//
// v1.8.0 — the accepted set is canonical English ids PLUS the legacy
// German aliases, so an iOS client still PUTting `blutdruck` / `puls` /
// … passes validation rather than tripping a 422 on the rename.
// `serializeInsightsLayout` normalises any legacy id onto its canonical
// English replacement before the row persists, so the stored blob is
// always canonical regardless of which id the client sent.
const tileIdEnum = z.enum(ACCEPTED_INSIGHTS_TILE_IDS);
const layoutSchema = z.object({
version: z.literal(1),
+20
View File
@@ -12,6 +12,7 @@ import {
} from "@/lib/api-response";
import { updateMeasurementSchema } from "@/lib/validations/measurement";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups";
import { Prisma } from "@/generated/prisma/client";
import { NextRequest } from "next/server";
@@ -172,6 +173,18 @@ export const PUT = apiHandler(
console.warn("[measurements] rollup recompute failed", err);
}
// v1.8.0 — drop the cached per-metric assessment rows this edit
// dirties so the next mount / nightly warm pass regenerates against
// the new value. An edit can re-type a row, so invalidate both the
// old and the new type's scopes. Fire-and-forget: never blocks the
// user's edit.
invalidateStatusInsightsForTypes(user.id, [
existing.type,
measurement.type,
]).catch((err) => {
console.warn("[measurements] status-insight invalidate failed", err);
});
return apiSuccess(measurement);
},
);
@@ -238,6 +251,13 @@ export const DELETE = apiHandler(
console.warn("[measurements] rollup recompute failed", err);
}
// v1.8.0 — drop the cached per-metric assessment rows this deletion
// dirties so the next mount / nightly warm pass regenerates against
// the reduced history. Fire-and-forget: never blocks the user's delete.
invalidateStatusInsightsForTypes(user.id, [existing.type]).catch((err) => {
console.warn("[measurements] status-insight invalidate failed", err);
});
return apiSuccess({ deleted: true });
},
);
+12
View File
@@ -46,6 +46,7 @@ import { deviceTypeEnum } from "@/lib/validations/source-priority";
import { checkRateLimit } from "@/lib/rate-limit";
import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import {
recomputeBucketsForMeasurement,
collapseToTypeDayKeys,
@@ -484,6 +485,17 @@ async function postBatch(request: NextRequest): Promise<Response> {
for (const k of insertedKeys) {
await recomputeBucketsForMeasurement(user.id, k.type, k.measuredAt);
}
// v1.8.0 — drop the cached per-metric assessment rows the ingested
// types dirty so the next mount / nightly warm pass regenerates
// them against the new data instead of serving stale text.
// Fire-and-forget: never a blocker on the user's ingest.
invalidateStatusInsightsForTypes(
user.id,
insertedKeys.map((k) => k.type),
).catch((err) => {
console.warn("[measurements] status-insight invalidate failed", err);
});
} catch (err) {
console.warn("[measurements] rollup recompute failed", err);
}
@@ -35,6 +35,7 @@ import {
safeJson,
} from "@/lib/api-response";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import {
recomputeBucketsForMeasurement,
collapseToTypeDayKeys,
@@ -152,6 +153,17 @@ async function deleteByExternalIds(request: NextRequest): Promise<Response> {
} catch (err) {
console.warn("[measurements] rollup recompute failed", err);
}
// v1.8.0 — drop the cached per-metric assessment rows the deleted
// types dirty so the next mount / nightly warm pass regenerates
// against the reconciled history. Fire-and-forget: never blocks the
// iOS reconciliation.
invalidateStatusInsightsForTypes(
user.id,
affectedRows.map((row) => row.type),
).catch((err) => {
console.warn("[measurements] status-insight invalidate failed", err);
});
}
return apiSuccess({ deletedCount: result.count });
+18
View File
@@ -28,6 +28,7 @@ import {
} from "@/lib/measurements/drain-per-sample-cumulative";
import { withIdempotency } from "@/lib/idempotency";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import {
recomputeBucketsForMeasurement,
collapseToTypeDayKeys,
@@ -664,6 +665,16 @@ async function postMeasurement(request: NextRequest) {
for (const k of keys) {
await recomputeBucketsForMeasurement(user.id, k.type, k.measuredAt);
}
// v1.8.0 — drop the cached per-metric assessment rows the ingested
// types dirty so the next mount / nightly warm pass regenerates
// them against the new data. Fire-and-forget: never blocks ingest.
invalidateStatusInsightsForTypes(
user.id,
keys.map((k) => k.type),
).catch((err) => {
console.warn("[measurements] status-insight invalidate failed", err);
});
} catch (err) {
console.warn("[measurements] rollup recompute failed", err);
}
@@ -768,5 +779,12 @@ async function postMeasurement(request: NextRequest) {
console.warn("[measurements] rollup recompute failed", err);
}
// v1.8.0 — drop the cached per-metric assessment rows this type
// dirties so the next mount / nightly warm pass regenerates against
// the new row. Fire-and-forget: never blocks the user's write.
invalidateStatusInsightsForTypes(user.id, [measurement.type]).catch((err) => {
console.warn("[measurements] status-insight invalidate failed", err);
});
return apiSuccess(measurement, 201);
}
@@ -0,0 +1,131 @@
import { describe, it, expect } from "vitest";
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
/**
* v1.8.0 — explainer-metric key guard.
*
* `<MetricExplainer>` builds its i18n lookups from a template literal:
*
* t(`insights.subPage.explainer.${metric}Title`)
* t(`insights.subPage.explainer.${metric}Body`)
*
* The static `i18n-call-site-coverage.test.ts` only sees literal
* `t("…")` calls, so a typo in an `explainerMetric=` prop would resolve
* to a missing key and paint the raw key string into the popover — with
* no CI signal. This test closes that gap the same way the
* Health-Score provenance key tests pin their dynamic keys: it walks
* every `explainerMetric="…"` value used under `src/app/insights/**`
* (plus the shared `healthkit-metric-page` scaffold) and asserts that
* both the `<value>Title` and `<value>Body` leaves exist in
* `messages/en.json`. `i18n-locale-integrity.test.ts` then propagates
* the EN guarantee across the other five locales.
*/
const ROOT = join(__dirname, "../../../..");
const INSIGHTS_DIR = join(ROOT, "src/app/insights");
const HEALTHKIT_PAGE = join(
ROOT,
"src/components/insights/healthkit-metric-page.tsx",
);
const EN_BUNDLE_PATH = join(ROOT, "messages/en.json");
function walk(dir: string, out: string[] = []): string[] {
for (const name of readdirSync(dir)) {
const p = join(dir, name);
if (statSync(p).isDirectory()) {
if (name === "__tests__") continue;
walk(p, out);
} else if (name.endsWith(".tsx")) {
out.push(p);
}
}
return out;
}
interface PropSite {
value: string;
file: string;
line: number;
}
// Match `explainerMetric="someValue"` — a string-literal prop. The
// component contract only accepts a static string, so a dynamic
// `explainerMetric={expr}` would be a different (and unverifiable)
// shape; none exist in the tree and we assert that below.
const LITERAL_PATTERN = /explainerMetric="([a-zA-Z][a-zA-Z0-9]*)"/g;
const DYNAMIC_PATTERN = /explainerMetric=\{/;
function collectSites(): { sites: PropSite[]; dynamic: PropSite[] } {
const files = [...walk(INSIGHTS_DIR), HEALTHKIT_PAGE];
const sites: PropSite[] = [];
const dynamic: PropSite[] = [];
for (const file of files) {
const lines = readFileSync(file, "utf8").split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// The scaffold declares + forwards the prop; ignore the type
// declaration + the pass-through (`explainerMetric={explainerMetric}`)
// — those are not call sites that pin a concrete key.
if (file === HEALTHKIT_PAGE) continue;
let m: RegExpExecArray | null;
LITERAL_PATTERN.lastIndex = 0;
while ((m = LITERAL_PATTERN.exec(line)) !== null) {
sites.push({ value: m[1], file: file.slice(ROOT.length + 1), line: i + 1 });
}
if (DYNAMIC_PATTERN.test(line)) {
dynamic.push({ value: line.trim(), file: file.slice(ROOT.length + 1), line: i + 1 });
}
}
}
return { sites, dynamic };
}
function hasLeaf(obj: unknown, dotted: string): boolean {
let cur: unknown = obj;
for (const part of dotted.split(".")) {
if (cur && typeof cur === "object" && part in (cur as Record<string, unknown>)) {
cur = (cur as Record<string, unknown>)[part];
} else {
return false;
}
}
return typeof cur === "string";
}
describe("insights explainerMetric key coverage", () => {
const en = JSON.parse(readFileSync(EN_BUNDLE_PATH, "utf8"));
const { sites, dynamic } = collectSites();
it("finds explainerMetric props across the insights pages", () => {
// Guards against the walker silently matching nothing (e.g. a refactor
// that renames the prop). The category surface carries dozens of pages.
expect(sites.length).toBeGreaterThan(20);
});
it("uses only string-literal explainerMetric props (no dynamic expressions)", () => {
// A dynamic value can't be statically verified against the bundle, so
// the contract is literal-only. If this ever fails, either inline the
// literal or extend this guard to resolve the expression.
expect(dynamic).toEqual([]);
});
it("every explainerMetric value resolves a Title + Body leaf in messages/en.json", () => {
const missing: string[] = [];
for (const site of sites) {
const titleKey = `insights.subPage.explainer.${site.value}Title`;
const bodyKey = `insights.subPage.explainer.${site.value}Body`;
if (!hasLeaf(en, titleKey)) {
missing.push(` ❌ ${titleKey} (${site.file}:${site.line})`);
}
if (!hasLeaf(en, bodyKey)) {
missing.push(` ❌ ${bodyKey} (${site.file}:${site.line})`);
}
}
if (missing.length > 0) {
throw new Error(
`Found ${missing.length} explainerMetric key(s) missing from messages/en.json:\n${missing.join("\n")}`,
);
}
});
});
@@ -5,7 +5,7 @@ import { Flame } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.4.32 — `/insights/aktive-energie`.
* v1.4.32 — `/insights/active-energy`.
*
* Active-energy-burned sub-page. The MeasurementType is cumulative
* (kcal per day, per R-A Option A); the v1.4.30 server-side daily-
@@ -20,6 +20,7 @@ export default function InsightsActiveEnergyPage() {
insightMetric="ACTIVE_ENERGY_BURNED"
chartKey="activeEnergy"
i18nPrefix="insights.activeEnergy"
explainerMetric="activeEnergy"
color="#ffb86c"
unit="kcal"
yAxisUnit="kcal"
@@ -5,7 +5,7 @@ import { Volume2 } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/laermereignisse`.
* v1.7.0 — `/insights/audio-events`.
*
* AUDIO_EXPOSURE_EVENT sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
@@ -19,6 +19,7 @@ export default function InsightsLaermereignissePage() {
insightMetric="AUDIO_EXPOSURE_EVENT"
chartKey="audioExposureEvent"
i18nPrefix="insights.audioExposureEvent"
explainerMetric="audioEvents"
color="#ff79c6"
unit=""
yAxisUnit=""
@@ -5,7 +5,7 @@ import { Droplet } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/blutzucker`.
* v1.7.0 — `/insights/blood-glucose`.
*
* BLOOD_GLUCOSE sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
@@ -19,6 +19,7 @@ export default function InsightsBlutzuckerPage() {
insightMetric="BLOOD_GLUCOSE"
chartKey="bloodGlucose"
i18nPrefix="insights.bloodGlucose"
explainerMetric="bloodGlucose"
color="#ff79c6"
unit="mg/dL"
yAxisUnit="mg/dL"
@@ -13,11 +13,12 @@ import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { getBpTargets } from "@/lib/analytics/bp-targets";
/**
* v1.4.25 W4 — `/insights/blutdruck`.
* v1.4.25 W4 — `/insights/blood-pressure`.
*
* Routed Blood-Pressure sub-page. The mother page hosts the overview
* (hero + briefing + advisor); this page concentrates on BP-only depth:
@@ -51,6 +52,7 @@ export default function InsightsBlutdruckPage() {
<SubPageShell
title={t("insights.bloodPressureSectionTitle")}
description={t("insights.subPage.blutdruckDescription")}
explainerMetric="bloodPressure"
>
<MetricEmptyState
icon={<HeartPulse className="size-6" />}
@@ -98,6 +100,7 @@ export default function InsightsBlutdruckPage() {
<SubPageShell
title={t("insights.bloodPressureSectionTitle")}
description={t("insights.subPage.blutdruckDescription")}
explainerMetric="bloodPressure"
>
<HealthChartDynamic
chartKey="bp"
@@ -111,6 +114,8 @@ export default function InsightsBlutdruckPage() {
userTimezone={user?.timezone}
/>
<MetricTargetSummary slug="blood-pressure" />
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<HeartPulse className="h-5 w-5" />}
+6
View File
@@ -14,6 +14,7 @@ import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { SubPageShell } from "@/components/insights/sub-page-shell";
/**
@@ -58,6 +59,7 @@ export default function InsightsBmiPage() {
<SubPageShell
title={t("insights.bmiSectionTitle")}
description={t("insights.subPage.bmiDescription")}
explainerMetric="bmi"
>
<MetricEmptyState
icon={<Ruler className="size-6" />}
@@ -81,6 +83,7 @@ export default function InsightsBmiPage() {
<SubPageShell
title={t("insights.bmiSectionTitle")}
description={t("insights.subPage.bmiDescription")}
explainerMetric="bmi"
>
<EmptyState
icon={<Ruler className="size-6" />}
@@ -106,6 +109,7 @@ export default function InsightsBmiPage() {
<SubPageShell
title={t("insights.bmiSectionTitle")}
description={t("insights.subPage.bmiDescription")}
explainerMetric="bmi"
>
<HealthChartDynamic
chartKey="bmi"
@@ -119,6 +123,8 @@ export default function InsightsBmiPage() {
userTimezone={user?.timezone}
/>
<MetricTargetSummary slug="bmi" />
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<Ruler className="h-5 w-5" />}
@@ -5,7 +5,7 @@ import { Thermometer } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.4.32 — `/insights/koerpertemperatur`.
* v1.4.32 — `/insights/body-temperature`.
*
* Body-temperature sub-page. Canonical storage is Celsius. Distinct
* from skin temperature (Withings ScanWatch dermal reading) — surface
@@ -19,6 +19,7 @@ export default function InsightsBodyTemperaturePage() {
insightMetric="BODY_TEMPERATURE"
chartKey="bodyTemperature"
i18nPrefix="insights.bodyTemperature"
explainerMetric="bodyTemperature"
color="#ff79c6"
unit="°C"
yAxisUnit="°C"
@@ -5,7 +5,7 @@ import { Droplet } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/koerperwasser`.
* v1.7.0 — `/insights/body-water`.
*
* TOTAL_BODY_WATER sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
@@ -19,6 +19,7 @@ export default function InsightsKoerperwasserPage() {
insightMetric="TOTAL_BODY_WATER"
chartKey="totalBodyWater"
i18nPrefix="insights.totalBodyWater"
explainerMetric="bodyWater"
color="#8be9fd"
unit="kg"
yAxisUnit="kg"
@@ -5,7 +5,7 @@ import { Bone } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/knochenmasse`.
* v1.7.0 — `/insights/bone-mass`.
*
* BONE_MASS sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
@@ -19,6 +19,7 @@ export default function InsightsKnochenmassePage() {
insightMetric="BONE_MASS"
chartKey="boneMass"
i18nPrefix="insights.boneMass"
explainerMetric="boneMass"
color="#ffb86c"
unit="kg"
yAxisUnit="kg"
@@ -5,7 +5,7 @@ import { Sun } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/tageslicht`.
* v1.7.0 — `/insights/daylight`.
*
* TIME_IN_DAYLIGHT sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
@@ -19,6 +19,7 @@ export default function InsightsTageslichtPage() {
insightMetric="TIME_IN_DAYLIGHT"
chartKey="timeInDaylight"
i18nPrefix="insights.timeInDaylight"
explainerMetric="daylight"
color="#50fa7b"
unit="min"
yAxisUnit="min"
@@ -5,7 +5,7 @@ import { Footprints } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/doppelstandphase`.
* v1.7.0 — `/insights/double-support-time`.
*
* WALKING_DOUBLE_SUPPORT sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
@@ -19,6 +19,7 @@ export default function InsightsDoppelstandphasePage() {
insightMetric="WALKING_DOUBLE_SUPPORT"
chartKey="walkingDoubleSupport"
i18nPrefix="insights.walkingDoubleSupport"
explainerMetric="doubleSupportTime"
color="#50fa7b"
unit="%"
yAxisUnit="%"
@@ -5,7 +5,7 @@ import { Volume2 } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/laermbelastung`.
* v1.7.0 — `/insights/environmental-audio`.
*
* AUDIO_EXPOSURE_ENV sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
@@ -19,6 +19,7 @@ export default function InsightsLaermbelastungPage() {
insightMetric="AUDIO_EXPOSURE_ENV"
chartKey="audioExposureEnv"
i18nPrefix="insights.audioExposureEnv"
explainerMetric="environmentalAudio"
color="#ff79c6"
unit="dBA"
yAxisUnit="dBA"
@@ -5,7 +5,7 @@ import { Scale } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/fettfreie-masse`.
* v1.7.0 — `/insights/fat-free-mass`.
*
* FAT_FREE_MASS sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
@@ -19,6 +19,7 @@ export default function InsightsFettfreieMassePage() {
insightMetric="FAT_FREE_MASS"
chartKey="fatFreeMass"
i18nPrefix="insights.fatFreeMass"
explainerMetric="fatFreeMass"
color="#bd93f9"
unit="kg"
yAxisUnit="kg"
@@ -5,7 +5,7 @@ import { Droplets } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/fettmasse`.
* v1.7.0 — `/insights/fat-mass`.
*
* FAT_MASS sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
@@ -19,6 +19,7 @@ export default function InsightsFettmassePage() {
insightMetric="FAT_MASS"
chartKey="fatMass"
i18nPrefix="insights.fatMass"
explainerMetric="fatMass"
color="#ffb86c"
unit="kg"
yAxisUnit="kg"
@@ -5,7 +5,7 @@ import { TrendingUp } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/stockwerke`.
* v1.7.0 — `/insights/flights-climbed`.
*
* FLIGHTS_CLIMBED sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
@@ -19,6 +19,7 @@ export default function InsightsStockwerkePage() {
insightMetric="FLIGHTS_CLIMBED"
chartKey="flightsClimbed"
i18nPrefix="insights.flightsClimbed"
explainerMetric="flightsClimbed"
color="#50fa7b"
unit="flights"
yAxisUnit="flights"
@@ -5,7 +5,7 @@ import { Headphones } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/kopfhoererpegel`.
* v1.7.0 — `/insights/headphone-audio`.
*
* AUDIO_EXPOSURE_HEADPHONE sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
@@ -19,6 +19,7 @@ export default function InsightsKopfhoererpegelPage() {
insightMetric="AUDIO_EXPOSURE_HEADPHONE"
chartKey="audioExposureHeadphone"
i18nPrefix="insights.audioExposureHeadphone"
explainerMetric="headphoneAudio"
color="#ff79c6"
unit="dBA"
yAxisUnit="dBA"
+1
View File
@@ -20,6 +20,7 @@ export default function InsightsHrvPage() {
insightMetric="HEART_RATE_VARIABILITY"
chartKey="hrv"
i18nPrefix="insights.hrv"
explainerMetric="hrv"
color="#bd93f9"
unit="ms"
yAxisUnit="ms"
@@ -5,7 +5,7 @@ import { Scale } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/magermasse`.
* v1.7.0 — `/insights/lean-body-mass`.
*
* LEAN_BODY_MASS sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
@@ -19,6 +19,7 @@ export default function InsightsMagermassePage() {
insightMetric="LEAN_BODY_MASS"
chartKey="leanBodyMass"
i18nPrefix="insights.leanBodyMass"
explainerMetric="leanBodyMass"
color="#bd93f9"
unit="kg"
yAxisUnit="kg"
@@ -15,12 +15,13 @@ import { Progress } from "@/components/ui/progress";
import { ComplianceHeatmap } from "@/components/charts/compliance-heatmap";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { TherapyTimeline } from "@/components/insights/therapy-timeline";
import { MedicationCardHeader } from "@/components/medications/MedicationCardHeader";
/**
* v1.4.25 W4 — `/insights/medikamente`.
* v1.4.25 W4 — `/insights/medications`.
*
* Routed Medication-Compliance sub-page. Mirrors the per-medication
* grid from the v1.4.24 mother page (compliance bars + heatmap +
@@ -111,7 +112,10 @@ export default function InsightsMedikamentePage() {
if (isComprehensiveLoading) {
return (
<SubPageShell title={t("insights.medicationCompliance")}>
<SubPageShell
title={t("insights.medicationCompliance")}
explainerMetric="medications"
>
<div className="flex items-center justify-center py-12">
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
</div>
@@ -124,7 +128,10 @@ export default function InsightsMedikamentePage() {
// gate reads `medications.length > 0`. CTA targets `/medications`
// (the dedicated medication-management surface).
return (
<SubPageShell title={t("insights.medicationCompliance")}>
<SubPageShell
title={t("insights.medicationCompliance")}
explainerMetric="medications"
>
<EmptyState
icon={<Pill className="size-6" />}
title={t("insights.emptyState.medication.title")}
@@ -149,6 +156,7 @@ export default function InsightsMedikamentePage() {
<SubPageShell
title={t("insights.medicationCompliance")}
description={t("insights.subPage.medikamenteDescription")}
explainerMetric="medications"
>
<div
className={
@@ -158,7 +166,7 @@ export default function InsightsMedikamentePage() {
{medications.map((med) => {
const medicationSummary = medicationSummaryById.get(med.id);
// UI-H3 — route every medication-list row (medications page
// + insights/medikamente) through `<MedicationCardHeader>`
// + insights/medications) through `<MedicationCardHeader>`
// so the surface stays one shape: `{name} {dose}` on line 1
// + outline category badge on line 2. The streak chip rides
// the `stateBadges` slot so a strong streak still surfaces.
@@ -235,6 +243,8 @@ export default function InsightsMedikamentePage() {
})}
</div>
<MetricTargetSummary slug="medications" />
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<Pill className="h-5 w-5" />}
@@ -15,10 +15,11 @@ import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { SubPageShell } from "@/components/insights/sub-page-shell";
/**
* v1.4.25 W4 — `/insights/stimmung`.
* v1.4.25 W4 — `/insights/mood`.
*
* Routed Mood sub-page. Unlike the mother page (which hid the mood
* section entirely when no mood data existed), a dedicated sub-page
@@ -75,6 +76,7 @@ export default function InsightsStimmungPage() {
<SubPageShell
title={t("insights.moodSectionTitle")}
description={t("insights.subPage.stimmungDescription")}
explainerMetric="mood"
>
<MetricEmptyState
icon={<Smile className="size-6" />}
@@ -95,6 +97,7 @@ export default function InsightsStimmungPage() {
<SubPageShell
title={t("insights.moodSectionTitle")}
description={t("insights.subPage.stimmungDescription")}
explainerMetric="mood"
>
<MoodChart
chartKey="mood"
@@ -102,6 +105,8 @@ export default function InsightsStimmungPage() {
userTimezone={user?.timezone}
/>
<MetricTargetSummary slug="mood" />
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<Smile className="h-5 w-5" />}
@@ -5,7 +5,7 @@ import { Dumbbell } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/muskelmasse`.
* v1.7.0 — `/insights/muscle-mass`.
*
* MUSCLE_MASS sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
@@ -19,6 +19,7 @@ export default function InsightsMuskelmassePage() {
insightMetric="MUSCLE_MASS"
chartKey="muscleMass"
i18nPrefix="insights.muscleMass"
explainerMetric="muscleMass"
color="#bd93f9"
unit="kg"
yAxisUnit="kg"
@@ -5,7 +5,7 @@ import { Wind } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.4.32 — `/insights/sauerstoff`.
* v1.4.32 — `/insights/oxygen`.
*
* Oxygen-saturation (SpO₂) sub-page. Canonical storage is percent
* (0..100); the chart axis runs 85..100 because clinical normal is
@@ -21,6 +21,7 @@ export default function InsightsOxygenSaturationPage() {
insightMetric="OXYGEN_SATURATION"
chartKey="oxygenSaturation"
i18nPrefix="insights.oxygenSaturation"
explainerMetric="oxygenSaturation"
color="#8be9fd"
unit="%"
yAxisUnit="%"
@@ -5,7 +5,7 @@ import { Activity } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/pulswellengeschwindigkeit`.
* v1.7.0 — `/insights/pulse-wave-velocity`.
*
* PULSE_WAVE_VELOCITY sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
@@ -19,6 +19,7 @@ export default function InsightsPulswellengeschwindigkeitPage() {
insightMetric="PULSE_WAVE_VELOCITY"
chartKey="pulseWaveVelocity"
i18nPrefix="insights.pulseWaveVelocity"
explainerMetric="pulseWaveVelocity"
color="#ff5555"
unit="m/s"
yAxisUnit="m/s"
@@ -13,6 +13,7 @@ import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { Vo2MaxChartRow } from "@/components/insights/vo2-max-chart-row";
import {
@@ -21,7 +22,7 @@ import {
} from "@/lib/analytics/pulse-targets";
/**
* v1.4.25 W4 — `/insights/puls`.
* v1.4.25 W4 — `/insights/pulse`.
*
* Routed Pulse sub-page. Renders the pulse chart with the personalized
* Karvonen-derived target band plus the per-section AI assessment.
@@ -61,6 +62,7 @@ export default function InsightsPulsPage() {
<SubPageShell
title={t("insights.pulseSectionTitle")}
description={t("insights.subPage.pulsDescription")}
explainerMetric="pulse"
>
<MetricEmptyState
icon={<Heart className="size-6" />}
@@ -111,6 +113,7 @@ export default function InsightsPulsPage() {
<SubPageShell
title={t("insights.pulseSectionTitle")}
description={t("insights.subPage.pulsDescription")}
explainerMetric="pulse"
>
<HealthChartDynamic
chartKey="pulse"
@@ -123,6 +126,8 @@ export default function InsightsPulsPage() {
userTimezone={user?.timezone}
/>
<MetricTargetSummary slug="pulse" />
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<Heart className="h-5 w-5" />}
@@ -5,7 +5,7 @@ import { Wind } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/atemfrequenz`.
* v1.7.0 — `/insights/respiratory-rate`.
*
* RESPIRATORY_RATE sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
@@ -19,6 +19,7 @@ export default function InsightsAtemfrequenzPage() {
insightMetric="RESPIRATORY_RATE"
chartKey="respiratoryRate"
i18nPrefix="insights.respiratoryRate"
explainerMetric="respiratoryRate"
color="#8be9fd"
unit="breaths/min"
yAxisUnit="breaths/min"
@@ -5,9 +5,9 @@ import { Heart } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.4.32 — `/insights/ruhepuls`.
* v1.4.32 — `/insights/resting-pulse`.
*
* Resting-heart-rate sub-page. Distinct from `/insights/puls` — Apple
* Resting-heart-rate sub-page. Distinct from `/insights/pulse` — Apple
* Health splits the steady-state daily `restingHeartRate` value from
* the spot `PULSE` samples logged manually or by Withings. The split
* here mirrors that distinction so the user has a "daily floor"
@@ -20,6 +20,7 @@ export default function InsightsRestingHrPage() {
insightMetric="RESTING_HEART_RATE"
chartKey="restingHr"
i18nPrefix="insights.restingHr"
explainerMetric="restingHr"
color="#ff5555"
unit="bpm"
yAxisUnit="bpm"
@@ -5,7 +5,7 @@ import { Thermometer } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/hauttemperatur`.
* v1.7.0 — `/insights/skin-temperature`.
*
* SKIN_TEMPERATURE sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
@@ -19,6 +19,7 @@ export default function InsightsHauttemperaturPage() {
insightMetric="SKIN_TEMPERATURE"
chartKey="skinTemperature"
i18nPrefix="insights.skinTemperature"
explainerMetric="skinTemperature"
color="#ffb86c"
unit="°C"
yAxisUnit="°C"
@@ -8,11 +8,12 @@ import { useTranslations } from "@/lib/i18n/context";
import { Button } from "@/components/ui/button";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { SleepOverview } from "@/components/insights/sleep-overview";
import { SubPageShell } from "@/components/insights/sub-page-shell";
/**
* v1.4.25 W4c — `/insights/schlaf`.
* v1.4.25 W4c — `/insights/sleep`.
*
* The Sleep sub-page surfaces the per-stage breakdown + duration trend
* the v1.4.23 schema gained but never rendered. All charts live inside
@@ -37,6 +38,7 @@ export default function InsightsSchlafPage() {
<SubPageShell
title={t("insights.sleep.title")}
description={t("insights.sleep.description")}
explainerMetric="sleep"
>
<MetricEmptyState
icon={<Moon className="size-6" />}
@@ -59,9 +61,12 @@ export default function InsightsSchlafPage() {
<SubPageShell
title={t("insights.sleep.title")}
description={t("insights.sleep.description")}
explainerMetric="sleep"
>
<SleepOverview />
<MetricTargetSummary slug="sleep" />
{/*
v1.4.28 (BK-UI-StatusSchlaf) — the six sibling sub-pages all
mount `<InsightStatusCard>` underneath the chart so the user
@@ -5,7 +5,7 @@ import { Footprints } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/schrittlaenge`.
* v1.7.0 — `/insights/step-length`.
*
* WALKING_STEP_LENGTH sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
@@ -19,6 +19,7 @@ export default function InsightsSchrittlaengePage() {
insightMetric="WALKING_STEP_LENGTH"
chartKey="walkingStepLength"
i18nPrefix="insights.walkingStepLength"
explainerMetric="stepLength"
color="#50fa7b"
unit="m"
yAxisUnit="m"
@@ -5,7 +5,7 @@ import { HeartPulse } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/gefaessalter`.
* v1.7.0 — `/insights/vascular-age`.
*
* VASCULAR_AGE sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
@@ -19,6 +19,7 @@ export default function InsightsGefaessalterPage() {
insightMetric="VASCULAR_AGE"
chartKey="vascularAge"
i18nPrefix="insights.vascularAge"
explainerMetric="vascularAge"
color="#ff79c6"
unit="years"
yAxisUnit="years"
@@ -5,7 +5,7 @@ import { Droplets } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/viszeralfett`.
* v1.7.0 — `/insights/visceral-fat`.
*
* VISCERAL_FAT sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
@@ -19,6 +19,7 @@ export default function InsightsViszeralfettPage() {
insightMetric="VISCERAL_FAT"
chartKey="visceralFat"
i18nPrefix="insights.visceralFat"
explainerMetric="visceralFat"
color="#ffb86c"
unit=""
yAxisUnit=""
@@ -5,7 +5,7 @@ import { Footprints } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/gangasymmetrie`.
* v1.7.0 — `/insights/walking-asymmetry`.
*
* WALKING_ASYMMETRY sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
@@ -19,6 +19,7 @@ export default function InsightsGangasymmetriePage() {
insightMetric="WALKING_ASYMMETRY"
chartKey="walkingAsymmetry"
i18nPrefix="insights.walkingAsymmetry"
explainerMetric="walkingAsymmetry"
color="#50fa7b"
unit="%"
yAxisUnit="%"
@@ -5,7 +5,7 @@ import { Footprints } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/gehstrecke`.
* v1.7.0 — `/insights/walking-distance`.
*
* WALKING_RUNNING_DISTANCE sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
@@ -19,6 +19,7 @@ export default function InsightsGehstreckePage() {
insightMetric="WALKING_RUNNING_DISTANCE"
chartKey="walkingRunningDistance"
i18nPrefix="insights.walkingRunningDistance"
explainerMetric="walkingDistance"
color="#50fa7b"
unit="km"
yAxisUnit="km"
@@ -5,7 +5,7 @@ import { HeartPulse } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/gehpuls`.
* v1.7.0 — `/insights/walking-heart-rate`.
*
* WALKING_HEART_RATE_AVERAGE sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
@@ -19,6 +19,7 @@ export default function InsightsGehpulsPage() {
insightMetric="WALKING_HEART_RATE_AVERAGE"
chartKey="walkingHeartRateAverage"
i18nPrefix="insights.walkingHeartRateAverage"
explainerMetric="walkingHeartRate"
color="#ff5555"
unit="bpm"
yAxisUnit="bpm"
@@ -5,7 +5,7 @@ import { Gauge } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/gehgeschwindigkeit`.
* v1.7.0 — `/insights/walking-speed`.
*
* WALKING_SPEED sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
@@ -19,6 +19,7 @@ export default function InsightsGehgeschwindigkeitPage() {
insightMetric="WALKING_SPEED"
chartKey="walkingSpeed"
i18nPrefix="insights.walkingSpeed"
explainerMetric="walkingSpeed"
color="#50fa7b"
unit="km/h"
yAxisUnit="km/h"
@@ -5,7 +5,7 @@ import { Gauge } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/gangstabilitaet`.
* v1.7.0 — `/insights/walking-steadiness`.
*
* WALKING_STEADINESS sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
@@ -19,6 +19,7 @@ export default function InsightsGangstabilitaetPage() {
insightMetric="WALKING_STEADINESS"
chartKey="walkingSteadiness"
i18nPrefix="insights.walkingSteadiness"
explainerMetric="walkingSteadiness"
color="#50fa7b"
unit="%"
yAxisUnit="%"
@@ -13,11 +13,12 @@ import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { buildWeightBandsFromHeight } from "@/lib/analytics/value-bands";
/**
* v1.4.25 W4 — `/insights/gewicht`.
* v1.4.25 W4 — `/insights/weight`.
*
* Routed Weight sub-page. Renders the weight chart with the user's
* height-derived green/orange/red bands plus the per-section AI
@@ -43,6 +44,7 @@ export default function InsightsGewichtPage() {
<SubPageShell
title={t("insights.weightSectionTitle")}
description={t("insights.subPage.gewichtDescription")}
explainerMetric="weight"
>
<MetricEmptyState
icon={<Scale className="size-6" />}
@@ -72,6 +74,7 @@ export default function InsightsGewichtPage() {
<SubPageShell
title={t("insights.weightSectionTitle")}
description={t("insights.subPage.gewichtDescription")}
explainerMetric="weight"
>
<HealthChartDynamic
chartKey="weight"
@@ -84,6 +87,8 @@ export default function InsightsGewichtPage() {
userTimezone={user?.timezone}
/>
<MetricTargetSummary slug="weight" />
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<Scale className="h-5 w-5" />}
+1
View File
@@ -35,6 +35,7 @@ export default function InsightsWorkoutsPage() {
<SubPageShell
title={t("insights.workouts.title")}
description={t("insights.workouts.description")}
explainerMetric="workouts"
>
{isLoading ? (
<div data-slot="workouts-loading" className="space-y-2">
@@ -0,0 +1,151 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider, useTranslations } from "@/lib/i18n/context";
import { MetricExplainer } from "../metric-explainer";
/**
* v1.8.0 — `<MetricExplainer>` unit tests.
*
* The explainer is the `?` glyph next to a metric sub-page heading that
* opens a static "What is X?" read on tap / Enter / Space. Tests run
* through SSR so they don't depend on a browser surface: the trigger
* button is always present, the popover / sheet body mounts only after
* the user activates it. The load-bearing constraints pinned here are
* the a11y contract (real button, labelled, disclosure semantics, 44 px
* hit surface) and that every locale ships the title + body copy.
*/
function ssr(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
/**
* Probe that resolves the explainer keys through the same `t()` calls
* the component uses, so a dropped locale key fails here at the call
* site instead of silently rendering the literal key path on the page.
*/
function KeyProbe({ metric }: { metric: string }) {
const { t } = useTranslations();
return (
<div>
<span data-slot="probe-title">
{t(`insights.subPage.explainer.${metric}Title`)}
</span>
<span data-slot="probe-body">
{t(`insights.subPage.explainer.${metric}Body`)}
</span>
</div>
);
}
// v1.8.0 — English key segments (ADR-0001). Every routed insights
// category page now passes one of these; the explainer copy ships in
// all six locales.
const METRICS = [
"bloodPressure",
"pulse",
"weight",
"bmi",
"sleep",
"mood",
"medications",
"restingHr",
"hrv",
"oxygenSaturation",
"bodyTemperature",
"activeEnergy",
"workouts",
"respiratoryRate",
"bodyWater",
"boneMass",
"fatFreeMass",
"fatMass",
"muscleMass",
"visceralFat",
"leanBodyMass",
"flightsClimbed",
"walkingDistance",
"walkingSteadiness",
"walkingHeartRate",
"walkingAsymmetry",
"doubleSupportTime",
"stepLength",
"walkingSpeed",
"pulseWaveVelocity",
"vascularAge",
"environmentalAudio",
"headphoneAudio",
"audioEvents",
"daylight",
"bloodGlucose",
"skinTemperature",
] as const;
describe("<MetricExplainer>", () => {
it("renders an icon-only trigger button with a metric-specific accessible label (EN)", () => {
const html = ssr(<MetricExplainer metric="bloodPressure" />);
const trigger = html.match(
/<button[^>]*data-slot="metric-explainer-trigger"[^>]*>/,
);
expect(trigger).not.toBeNull();
expect(trigger?.[0]).toContain('aria-label="What is Blood pressure?"');
});
it("lifts the tap target to the 44 px WCAG 2.5.5 floor", () => {
// The glyph stays 14 px but the click surface inflates to 44 px via
// `min-h-11 min-w-11`; the surrounding heading row keeps its stride
// because `-my-3 -mx-2` swallows the extra reach. Pinned so a future
// refactor can't collapse the hit surface back to the visual chip.
const html = ssr(<MetricExplainer metric="pulse" />);
const trigger = html.match(
/<button[^>]*data-slot="metric-explainer-trigger"[^>]*>/,
);
expect(trigger?.[0]).toContain("min-h-11");
expect(trigger?.[0]).toContain("min-w-11");
});
it("threads disclosure semantics onto the trigger button", () => {
const html = ssr(<MetricExplainer metric="hrv" />);
const trigger = html.match(
/<button[^>]*data-slot="metric-explainer-trigger"[^>]*>/,
);
expect(trigger?.[0]).toContain("aria-expanded");
expect(trigger?.[0]).toContain("aria-controls");
});
it("does not paint the popover/sheet body on the initial SSR snapshot", () => {
// Closed-by-default: the body lives in a portal that mounts after
// the user activates the trigger. Pinning the negative keeps a
// future "open on render" refactor from leaking copy into the
// static markup.
const html = ssr(<MetricExplainer metric="weight" />);
expect(html).not.toContain('data-slot="metric-explainer-body"');
expect(html).not.toContain('data-slot="metric-explainer-title"');
});
it("localises the trigger label in German", () => {
const html = ssr(<MetricExplainer metric="sleep" />, "de");
const trigger = html.match(
/<button[^>]*data-slot="metric-explainer-trigger"[^>]*>/,
);
expect(trigger?.[0]).toContain('aria-label="Was ist Schlaf?"');
});
it("ships the title + body copy for every category metric in every locale", () => {
for (const locale of ["en", "de", "fr", "es", "it", "pl"] as const) {
for (const metric of METRICS) {
const html = renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<KeyProbe metric={metric} />
</I18nProvider>,
);
// A missing key surfaces as the literal `insights.subPage…`
// path, which never contains a space.
expect(html).not.toContain(`insights.subPage.explainer.${metric}`);
}
}
});
});
@@ -0,0 +1,128 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
/**
* v1.8.0 — `<MetricTargetSummary>` unit tests.
*
* The card surfaces the numeric target range + the in-target share on
* each insights category page, reading the same `/api/insights/targets`
* payload that powers `/targets`. The component depends on `useAuth`
* (gate) and TanStack Query (`useQuery`) for the payload, so both are
* mocked here and the assertions run through SSR — the load-bearing
* behaviour is the range/share rendering + the slug→type mapping, not
* the live fetch.
*/
vi.mock("@/hooks/use-auth", () => ({
useAuth: vi.fn(() => ({ isAuthenticated: true, user: null })),
}));
const useQueryMock = vi.fn();
vi.mock("@tanstack/react-query", () => ({
useQuery: (opts: unknown) => useQueryMock(opts),
}));
const { MetricTargetSummary } = await import("../metric-target-summary");
interface TargetsFixture {
targets: Array<{
type: string;
current: number | null;
unit: string;
range: { min: number; max: number } | null;
daysInRange30d: number;
daysLogged30d: number;
insufficientData: boolean;
}>;
bpDiastolic: { current: number | null; range: { min: number; max: number } | null };
}
function renderWith(slug: string, data: TargetsFixture | undefined) {
useQueryMock.mockReturnValue({ data });
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<MetricTargetSummary slug={slug} />
</I18nProvider>,
);
}
const WEIGHT_DATA: TargetsFixture = {
targets: [
{
type: "WEIGHT",
current: 72,
unit: "kg",
range: { min: 60.1, max: 80.9 },
daysInRange30d: 18,
daysLogged30d: 24,
insufficientData: false,
},
],
bpDiastolic: { current: null, range: null },
};
const BP_DATA: TargetsFixture = {
targets: [
{
type: "BLOOD_PRESSURE",
current: 124,
unit: "mmHg",
range: { min: 120, max: 129 },
daysInRange30d: 10,
daysLogged30d: 20,
insufficientData: false,
},
],
bpDiastolic: { current: 78, range: { min: 70, max: 79 } },
};
describe("<MetricTargetSummary>", () => {
it("renders the numeric range + in-target share for a simple metric", () => {
const html = renderWith("weight", WEIGHT_DATA);
// Trailing `.0` trimmed, single-decimal kept.
expect(html).toContain("Target: 60.1–80.9 kg");
// round(18/24*100) = 75
expect(html).toContain("75% of logged days within target");
});
it("stitches the systolic + diastolic bands for blood pressure", () => {
const html = renderWith("blood-pressure", BP_DATA);
expect(html).toContain("Target: 120–129 / 70–79 mmHg");
expect(html).toContain("50% of logged days within target");
});
it("renders the adjust-target link to /targets", () => {
const html = renderWith("weight", WEIGHT_DATA);
expect(html).toContain('href="/targets"');
expect(html).toContain("Adjust target range");
});
it("suppresses the share when the route flags insufficient data", () => {
const html = renderWith("weight", {
...WEIGHT_DATA,
targets: [{ ...WEIGHT_DATA.targets[0], insufficientData: true }],
});
expect(html).toContain("Target: 60.1–80.9 kg");
expect(html).not.toContain("of logged days within target");
});
it("renders nothing for a slug without a numeric target", () => {
const html = renderWith("hrv", undefined);
expect(html).toBe("");
});
it("renders nothing before the payload resolves", () => {
const html = renderWith("weight", undefined);
expect(html).toBe("");
});
it("renders nothing when the matching target has no range", () => {
const html = renderWith("weight", {
...WEIGHT_DATA,
targets: [{ ...WEIGHT_DATA.targets[0], range: null }],
});
expect(html).toBe("");
});
});
+8 -8
View File
@@ -39,23 +39,23 @@ import type {
* through to `null` and render as a plain row (no link wrap).
*/
const METRIC_HREF: Record<DailyBriefingKeyFinding["sourceMetric"], string | null> = {
bp: "/insights/blutdruck",
weight: "/insights/gewicht",
pulse: "/insights/puls",
mood: "/insights/stimmung",
compliance: "/insights/medikamente",
bp: "/insights/blood-pressure",
weight: "/insights/weight",
pulse: "/insights/pulse",
mood: "/insights/mood",
compliance: "/insights/medications",
// Apple Health / GLP-1 additive metrics — no dedicated sub-page yet.
// They render as static rows; v1.4.28 can wire each as they ship.
hrv: null,
sleep: "/insights/schlaf",
sleep: "/insights/sleep",
resting_hr: null,
steps: null,
active_energy: null,
flights: null,
distance: null,
vo2_max: "/insights/puls",
vo2_max: "/insights/pulse",
body_temp: null,
glp1_plateau: "/insights/medikamente",
glp1_plateau: "/insights/medications",
};
/**
@@ -80,6 +80,12 @@ export interface HealthKitMetricPageProps {
* (identity) so every existing page renders unchanged.
*/
valueScale?: number;
/**
* v1.8.0 — metric key threaded into `<SubPageShell explainerMetric>`
* so the `?` heading glyph opens the static "What is X?" explainer.
* Resolves `insights.subPage.explainer.<explainerMetric>{Title,Body}`.
*/
explainerMetric?: string;
}
export function HealthKitMetricPage({
@@ -95,6 +101,7 @@ export function HealthKitMetricPage({
emptyStateIcon,
coachPrefill,
valueScale,
explainerMetric,
}: HealthKitMetricPageProps) {
const { user, isAuthenticated } = useAuth();
const { t } = useTranslations();
@@ -115,7 +122,11 @@ export function HealthKitMetricPage({
</Button>
) : null;
return (
<SubPageShell title={title} description={description}>
<SubPageShell
title={title}
description={description}
explainerMetric={explainerMetric}
>
<MetricEmptyState
icon={emptyStateIcon}
title={t(`${i18nPrefix}.emptyState.title`)}
@@ -128,7 +139,11 @@ export function HealthKitMetricPage({
}
return (
<SubPageShell title={title} description={description}>
<SubPageShell
title={title}
description={description}
explainerMetric={explainerMetric}
>
<HealthChartDynamic
chartKey={chartKey}
types={[measurementType]}
+36 -36
View File
@@ -31,7 +31,7 @@ import {
* v1.4.25 W4 — routed tab strip for `/insights`.
*
* Behaviour evolved from the v1.4.25 W3 scroll-anchor version: each pill
* is now a `<Link>` to a routed sub-page (`/insights/blutdruck` …),
* is now a `<Link>` to a routed sub-page (`/insights/blood-pressure` …),
* `usePathname()` decides the active pill, and the strip is mounted in
* `src/app/insights/layout.tsx` so it persists across navigation
* without re-rendering. The CoachDrawer is NOT mounted in this layout —
@@ -116,47 +116,47 @@ const SUB_PAGE_TABS: Record<
{ labelKey: string; metric: InsightMetric }
> = {
// ── vitals ──
blutdruck: {
"blood-pressure": {
labelKey: "insights.navBloodPressure",
metric: "BLOOD_PRESSURE_SYS",
},
puls: { labelKey: "insights.navPulse", metric: "PULSE" },
sauerstoff: {
pulse: { labelKey: "insights.navPulse", metric: "PULSE" },
oxygen: {
labelKey: "insights.navOxygenSaturation",
metric: "OXYGEN_SATURATION",
},
koerpertemperatur: {
"body-temperature": {
labelKey: "insights.navBodyTemperature",
metric: "BODY_TEMPERATURE",
},
atemfrequenz: {
"respiratory-rate": {
labelKey: "insights.navRespiratoryRate",
metric: "RESPIRATORY_RATE",
},
// ── body composition ──
gewicht: { labelKey: "insights.navWeight", metric: "WEIGHT" },
weight: { labelKey: "insights.navWeight", metric: "WEIGHT" },
bmi: { labelKey: "insights.navBmi", metric: "BMI" },
koerperwasser: {
"body-water": {
labelKey: "insights.navTotalBodyWater",
metric: "TOTAL_BODY_WATER",
},
knochenmasse: { labelKey: "insights.navBoneMass", metric: "BONE_MASS" },
"fettfreie-masse": {
"bone-mass": { labelKey: "insights.navBoneMass", metric: "BONE_MASS" },
"fat-free-mass": {
labelKey: "insights.navFatFreeMass",
metric: "FAT_FREE_MASS",
},
fettmasse: { labelKey: "insights.navFatMass", metric: "FAT_MASS" },
muskelmasse: { labelKey: "insights.navMuscleMass", metric: "MUSCLE_MASS" },
viszeralfett: {
"fat-mass": { labelKey: "insights.navFatMass", metric: "FAT_MASS" },
"muscle-mass": { labelKey: "insights.navMuscleMass", metric: "MUSCLE_MASS" },
"visceral-fat": {
labelKey: "insights.navVisceralFat",
metric: "VISCERAL_FAT",
},
magermasse: {
"lean-body-mass": {
labelKey: "insights.navLeanBodyMass",
metric: "LEAN_BODY_MASS",
},
// ── activity ──
"aktive-energie": {
"active-energy": {
labelKey: "insights.navActiveEnergy",
metric: "ACTIVE_ENERGY_BURNED",
},
@@ -164,85 +164,85 @@ const SUB_PAGE_TABS: Record<
// availability helper reads `inputs.hasWorkouts` rather than a
// `summaries[…].count`.
workouts: { labelKey: "insights.navWorkouts", metric: "WORKOUTS" },
stockwerke: {
"flights-climbed": {
labelKey: "insights.navFlightsClimbed",
metric: "FLIGHTS_CLIMBED",
},
gehstrecke: {
"walking-distance": {
labelKey: "insights.navWalkingRunningDistance",
metric: "WALKING_RUNNING_DISTANCE",
},
gangstabilitaet: {
"walking-steadiness": {
labelKey: "insights.navWalkingSteadiness",
metric: "WALKING_STEADINESS",
},
gehpuls: {
"walking-heart-rate": {
labelKey: "insights.navWalkingHeartRateAverage",
metric: "WALKING_HEART_RATE_AVERAGE",
},
gangasymmetrie: {
"walking-asymmetry": {
labelKey: "insights.navWalkingAsymmetry",
metric: "WALKING_ASYMMETRY",
},
doppelstandphase: {
"double-support-time": {
labelKey: "insights.navWalkingDoubleSupport",
metric: "WALKING_DOUBLE_SUPPORT",
},
schrittlaenge: {
"step-length": {
labelKey: "insights.navWalkingStepLength",
metric: "WALKING_STEP_LENGTH",
},
gehgeschwindigkeit: {
"walking-speed": {
labelKey: "insights.navWalkingSpeed",
metric: "WALKING_SPEED",
},
// ── sleep ──
schlaf: { labelKey: "insights.navSleep", metric: "SLEEP_DURATION" },
sleep: { labelKey: "insights.navSleep", metric: "SLEEP_DURATION" },
// ── cardiovascular ──
ruhepuls: {
"resting-pulse": {
labelKey: "insights.navRestingHr",
metric: "RESTING_HEART_RATE",
},
hrv: { labelKey: "insights.navHrv", metric: "HEART_RATE_VARIABILITY" },
pulswellengeschwindigkeit: {
"pulse-wave-velocity": {
labelKey: "insights.navPulseWaveVelocity",
metric: "PULSE_WAVE_VELOCITY",
},
gefaessalter: {
"vascular-age": {
labelKey: "insights.navVascularAge",
metric: "VASCULAR_AGE",
},
// ── hearing ──
laermbelastung: {
"environmental-audio": {
labelKey: "insights.navAudioExposureEnv",
metric: "AUDIO_EXPOSURE_ENV",
},
kopfhoererpegel: {
"headphone-audio": {
labelKey: "insights.navAudioExposureHeadphone",
metric: "AUDIO_EXPOSURE_HEADPHONE",
},
laermereignisse: {
"audio-events": {
labelKey: "insights.navAudioExposureEvent",
metric: "AUDIO_EXPOSURE_EVENT",
},
// ── environment ──
tageslicht: {
daylight: {
labelKey: "insights.navTimeInDaylight",
metric: "TIME_IN_DAYLIGHT",
},
// ── metabolic ──
blutzucker: {
"blood-glucose": {
labelKey: "insights.navBloodGlucose",
metric: "BLOOD_GLUCOSE",
},
hauttemperatur: {
"skin-temperature": {
labelKey: "insights.navSkinTemperature",
metric: "SKIN_TEMPERATURE",
},
// ── mood ──
stimmung: { labelKey: "insights.navMood", metric: "MOOD" },
mood: { labelKey: "insights.navMood", metric: "MOOD" },
// ── events ──
medikamente: { labelKey: "insights.navMedication", metric: "MEDICATION" },
medications: { labelKey: "insights.navMedication", metric: "MEDICATION" },
};
/**
@@ -409,7 +409,7 @@ function InsightsTabStripImpl({
if (tab.kind === "link") {
// The overview pill matches the mother page exactly; the
// sub-page pills match a prefix so future nested routes
// (e.g. `/insights/schlaf/2026-05-11`) still highlight the
// (e.g. `/insights/sleep/2026-05-11`) still highlight the
// parent tab.
const isActive =
tab.href === INSIGHTS_OVERVIEW_PATH
@@ -0,0 +1,123 @@
"use client";
import { useId, useState } from "react";
import { HelpCircle } from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { useIsMobile } from "@/hooks/use-is-mobile";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
/**
* v1.8.0 — per-category "What is X?" explainer.
*
* A question-mark glyph that sits next to a metric sub-page heading and,
* on tap / Enter / Space, surfaces one to two layperson sentences that
* define the metric. The copy is static — there is no network call — so
* the explainer paints instantly and works offline. Each metric resolves
* its title + body off a stable i18n key pair:
*
* insights.subPage.explainer.<metric>Title
* insights.subPage.explainer.<metric>Body
*
* The surface choice mirrors `<HealthScoreDeltaExplainer>`: a popover on
* `md+`, a bottom-sheet on phone-class viewports. Both branches share the
* same body copy so the read is identical regardless of viewport.
*
* a11y: the trigger is a real `<button>` (Enter / Space activate it
* natively) carrying a descriptive `aria-label`, `aria-expanded`, and
* `aria-controls` pointing at the body. The optical glyph stays 14 px so
* it reads at the heading's height, but the hit surface lifts to the
* 44 px WCAG 2.5.5 floor via `min-h-11 min-w-11`; negative margins
* collapse the surrounding row back to its natural stride so the chip
* stays visually small while the reach is full-size.
*/
interface MetricExplainerProps {
/**
* The metric key. Drives the title + body lookup under
* `insights.subPage.explainer.<metric>{Title,Body}`.
*/
metric: string;
/** Optional className forwarded to the trigger button. */
className?: string;
}
export function MetricExplainer({ metric, className }: MetricExplainerProps) {
const { t } = useTranslations();
const isMobile = useIsMobile();
const [open, setOpen] = useState(false);
const bodyId = useId();
const title = t(`insights.subPage.explainer.${metric}Title`);
const body = t(`insights.subPage.explainer.${metric}Body`);
const triggerLabel = t("insights.subPage.explainer.trigger", { title });
const trigger = (
<button
type="button"
data-slot="metric-explainer-trigger"
aria-label={triggerLabel}
aria-expanded={open}
aria-controls={bodyId}
className={cn(
"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50",
"inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full",
"-my-3 -mx-2",
"transition-colors focus-visible:ring-2 focus-visible:outline-none",
className,
)}
onClick={() => setOpen(true)}
>
<HelpCircle className="h-3.5 w-3.5" aria-hidden="true" />
</button>
);
if (isMobile) {
return (
<>
{trigger}
<ResponsiveSheet open={open} onOpenChange={setOpen} title={title}>
<p
id={bodyId}
data-slot="metric-explainer-body"
className="text-muted-foreground text-sm leading-relaxed"
>
{body}
</p>
</ResponsiveSheet>
</>
);
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent
data-slot="metric-explainer-popover"
align="start"
sideOffset={6}
className="max-w-xs space-y-1.5"
>
<p
data-slot="metric-explainer-title"
className="text-foreground text-xs font-semibold"
>
{title}
</p>
<p
id={bodyId}
data-slot="metric-explainer-body"
className="text-muted-foreground text-xs leading-snug"
>
{body}
</p>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,173 @@
"use client";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { Target } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
import { Card, CardContent } from "@/components/ui/card";
/**
* v1.8.0 — surface the numeric target range + in-target share on each
* insights category page, directly beneath the chart / assessment.
*
* The data is the same `/api/insights/targets` payload that powers the
* `/targets` editing surface — this component reads the shared
* `queryKeys.insightsTargets()` cache, so on a session that has already
* visited `/targets` the read is free, and on a cold sub-page it warms a
* cache the `/targets` page then reuses. We never recompute the ranges
* here: the route is the single source of truth for the age-based ESH
* blood-pressure band, the WHO weight / BMI band, the Karvonen pulse
* band, the AASM sleep band, and the medication / mood / step targets.
*
* Each insights slug maps to one target `type`. Blood pressure is the one
* composite case: the route emits the systolic band on the
* `BLOOD_PRESSURE` target and the diastolic band on the top-level
* `bpDiastolic` slot, so we stitch them back into a `S/D` range string.
*
* The "in target" share reads `daysInRange30d / daysLogged30d` — the
* fraction of logged days over the last 30 whose mean reading landed in
* the green band. We hide the share alone when the route flags
* `insufficientData`; the range stays visible so the band is always
* legible. This matches the `/targets` consistency strip so the two
* surfaces never disagree.
*
* A discreet "Adjust target range" link routes to `/targets`, which
* stays the canonical place to view / tune the bands.
*/
interface TargetRange {
min: number;
max: number;
}
interface TargetItem {
type: string;
current: number | null;
unit: string;
range: TargetRange | null;
daysInRange30d: number;
daysLogged30d: number;
insufficientData: boolean;
}
interface TargetsResponse {
targets: TargetItem[];
bpDiastolic: {
current: number | null;
range: TargetRange | null;
};
}
/**
* Map each insights category slug to the target `type` the route emits.
* Slugs without a numeric target (HRV, oxygen saturation, body
* temperature, active energy, workouts, resting HR) are absent — the
* component renders nothing for them, so the sub-page collapses cleanly.
*/
const SLUG_TO_TARGET_TYPE: Record<string, string> = {
"blood-pressure": "BLOOD_PRESSURE",
weight: "WEIGHT",
bmi: "BMI",
pulse: "PULSE",
sleep: "SLEEP_DURATION",
mood: "MOOD_SCORE",
medications: "MEDICATION_COMPLIANCE",
};
interface MetricTargetSummaryProps {
/** Insights category slug, e.g. `"blood-pressure"`. */
slug: string;
}
/** Trim a trailing `.0` so whole-number bands read `120` not `120.0`. */
function fmt(value: number): string {
return Number.isInteger(value) ? String(value) : value.toFixed(1);
}
export function MetricTargetSummary({ slug }: MetricTargetSummaryProps) {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
const targetType = SLUG_TO_TARGET_TYPE[slug];
const { data } = useQuery({
queryKey: queryKeys.insightsTargets(),
queryFn: async () => {
const res = await fetch("/api/insights/targets");
if (!res.ok) throw new Error("Failed");
const json = await res.json();
return json.data as TargetsResponse;
},
enabled: isAuthenticated && targetType != null,
});
// No numeric target for this slug, or the payload hasn't resolved yet.
if (!targetType || !data) return null;
const target = data.targets.find((entry) => entry.type === targetType);
if (!target || !target.range) return null;
const { range, unit } = target;
// Compose the range string. Blood pressure stitches the systolic band
// (on this target) with the diastolic band (on `bpDiastolic`) so the
// user reads the familiar `S/D` pair.
let rangeLabel: string;
if (targetType === "BLOOD_PRESSURE" && data.bpDiastolic.range) {
const dia = data.bpDiastolic.range;
rangeLabel = t("insights.subPage.target.bpRange", {
sysMin: fmt(range.min),
sysMax: fmt(range.max),
diaMin: fmt(dia.min),
diaMax: fmt(dia.max),
unit,
});
} else {
rangeLabel = t("insights.subPage.target.range", {
min: fmt(range.min),
max: fmt(range.max),
unit,
});
}
// In-target share: fraction of logged days in the green band over the
// last 30. Suppressed when the route flagged insufficient data so the
// sub-page mirrors the `/targets` strip exactly.
const showShare =
!target.insufficientData && target.daysLogged30d > 0;
const sharePct = showShare
? Math.round((target.daysInRange30d / target.daysLogged30d) * 100)
: null;
return (
<Card data-slot="metric-target-summary">
<CardContent className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 py-3">
<div className="flex items-center gap-2.5">
<Target
className="text-dracula-green size-4 shrink-0"
aria-hidden="true"
/>
<div className="space-y-0.5">
<p className="text-sm font-medium">{rangeLabel}</p>
{sharePct != null ? (
<p className="text-muted-foreground text-xs">
{t("insights.subPage.target.inTargetShare", {
pct: sharePct,
})}
</p>
) : null}
</div>
</div>
<Link
href="/targets"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex min-h-11 items-center rounded-sm px-1 text-xs font-medium underline-offset-4 transition-colors hover:underline focus-visible:ring-2 focus-visible:outline-none"
>
{t("insights.subPage.target.adjustLink")}
</Link>
</CardContent>
</Card>
);
}
@@ -3,6 +3,7 @@
import { useEffect, useRef, type ReactNode } from "react";
import { Badge } from "@/components/ui/badge";
import { MetricExplainer } from "@/components/insights/metric-explainer";
import { useScrollResetOnRoute } from "@/hooks/use-scroll-reset-on-route";
import { cn } from "@/lib/utils";
@@ -34,6 +35,14 @@ export interface SubPageShellProps {
* page does not pass it; sub-pages use it to anchor the surface.
*/
description?: string;
/**
* v1.8.0 — when set, a `?` glyph next to the heading opens a static
* "What is X?" explainer (popover on desktop, bottom-sheet on phones).
* The value is the metric key feeding
* `insights.subPage.explainer.<metric>{Title,Body}`. Omitted on the
* mother page; every routed metric sub-page passes its key.
*/
explainerMetric?: string;
/**
* v1.4.27 MB7 / CF-35 — opt-in programmatic focus on mount.
*
@@ -56,6 +65,7 @@ export function SubPageShell({
title,
badge,
description,
explainerMetric,
focusOnMount = false,
children,
}: SubPageShellProps) {
@@ -99,6 +109,9 @@ export function SubPageShell({
>
{title}
</h1>
{explainerMetric ? (
<MetricExplainer metric={explainerMetric} />
) : null}
{badge ? (
<Badge variant="outline" className="border text-xs">
{badge}
+1 -1
View File
@@ -11,7 +11,7 @@ import {
} from "@/lib/medications/injection-sites";
/**
* v1.4.25 W4d — therapy-timeline view for /insights/medikamente.
* v1.4.25 W4d — therapy-timeline view for /insights/medications.
*
* Renders a chronological list of GLP-1 therapy events: dose changes,
* injections (with site if recorded), inventory events, side-effect tag
@@ -9,7 +9,7 @@ import { cn } from "@/lib/utils";
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
/**
* v1.4.25 W16a — VO2 max chart-row card for `/insights/puls`.
* v1.4.25 W16a — VO2 max chart-row card for `/insights/pulse`.
*
* The dashboard already carries an opt-in VO2 max trend tile (W8d.5);
* this is the deeper Insights surface. Renders one HealthChart for the
+95 -171
View File
@@ -1,192 +1,116 @@
import type { Locale } from "@/lib/i18n/config";
const BASE_SYSTEM_PROMPT_DE = `Du bist ein persönlicher Gesundheitsanalyst, der die Daten dieses Nutzers kennt. Deine Expertise umfasst Innere Medizin und Präventivmedizin. Deine Analysen basieren auf aktuellen medizinischen Leitlinien (ESH 2023, WHO, DGE, DEGAM), aber du beziehst dich immer auf die individuellen Werte und die persönliche Baseline des Nutzers.
/**
* Base system prompt for the per-metric Insights *assessment* cards.
*
* The seven status generators under `src/lib/insights/*-status.ts` each
* build a graded snapshot, send it through `runStatusCompletion`, and
* read back exactly one field — `summary`. Nothing else in the model's
* reply is rendered. So this prompt is tuned for one job: produce one
* short, grounded, warm assessment paragraph per metric.
*
* The snapshot shape every metric shares
* ---------------------------------------
* Each metric carries a `summary` block plus a graded `series`:
*
* summary { points, start, end, delta, mean, min, max }
* — `start`/`end` are the first/last daily-bucket values in the
* read window, `delta = end - start`, `mean`/`min`/`max` span
* the whole window. The headline numbers.
*
* series { recent[], weekly[], monthly[], yearly[] }
* — a time-graded view of the same metric:
* recent the last ~21 days, one row per day
* { date, min, max, mean, n }
* weekly the ~10 ISO weeks before that
* { weekISO, min, max, mean, n }
* monthly the ~12 calendar months before that
* { month, min, max, mean, n }
* yearly everything older, one row per year
* { year, min, max, mean, n, slope }
* The newest days are present individually; older spans are
* folded into week / month / year aggregates. A slice may be
* empty when the history is short or was shed for budget.
*
* The person's own baseline is read OUT of this series: the recent days
* are the short window; the monthly / yearly means are the long baseline.
* Compare the two. That comparison — not a population norm — is the point.
*
* Some metrics add precomputed Pearson correlations and a `targets`
* block; the per-metric section documents those.
*/
TONALITÄT UND ANSPRACHE:
- Verwende die zweite Person: "dein Blutdruck", "deine Werte", "dein Gewicht".
- Beginne mit positiven Befunden, bevor du auf Bedenken eingehst.
- Beziehe dich auf die eigene Baseline des Nutzers, nicht auf Bevölkerungsnormen (z.B. "dein systolischer Wert liegt 5 mmHg unter deinem 90-Tage-Durchschnitt" statt "unter dem Bevölkerungsdurchschnitt").
- Formuliere eine zentrale Handlungsempfehlung ("One Thing") als wichtigste nächste Aktion.
const BASE_SYSTEM_PROMPT_DE = `Du schreibst eine kurze, persönliche Einschätzung zu EINER Gesundheitsmetrik dieses Nutzers. Du kennst seine Daten und ordnest sie gegen seine EIGENE Baseline ein — nicht gegen Bevölkerungsnormen. Deine fachliche Grundlage sind anerkannte Leitlinien (ESH 2023, WHO, AASM, DGE, DEGAM); du beziehst dich darauf, ohne zu diagnostizieren.
DENKSCHRITTE (intern anwenden, nicht im Output zeigen):
1. Was hat sich verändert? (Vergleiche 7d vs. 30d vs. 90d vs. allTime)
2. Warum? (Korrelationen, Medikation, Stimmung, saisonale Muster)
3. Was tun? (Eine primäre Empfehlung + ergänzende Vorschläge)
DEINE DATENGRUNDLAGE (graded snapshot):
- summary: { points, start, end, delta, mean, min, max } — die Eckwerte des gesamten Lesefensters.
- series.recent[]: die jüngsten ~21 Tage, je ein Eintrag pro Tag ({ date, min, max, mean, n }).
- series.weekly[]: die ~10 Wochen davor, je ein ISO-Wochen-Aggregat.
- series.monthly[]: die ~12 Monate davor, je ein Monatsaggregat.
- series.yearly[]: alles ältere, je ein Jahr ({ year, mean, min, max, n, slope }).
- Die jüngsten Tage liegen also EINZELN vor, ältere Zeiträume als Wochen-, Monats- und Jahresaggregate.
- Lies die persönliche Baseline aus den weekly/monthly/yearly-Mitteln; das kurze Fenster sind die recent-Tage.
- Ein Slice kann leer sein, wenn die Historie kurz ist. Dann nicht so tun, als gäbe es ihn.
GRUNDREGELN:
- Evidenzbasiert: Referenziere Grenzwerte und Leitlinien explizit bei Bewertungen.
- Mustererkennung: Identifiziere Tageszeit-, Wochentag- und saisonale Trends.
- Korrelationen: Benenne Wechselwirkungen zwischen Medikation, Vitalwerten und Stimmung.
- Datenqualität: Bewerte die Aussagekraft basierend auf Messanzahl, -dichte und -aktualität.
* < 5 Messpunkte: "Noch nicht genügend Daten für eine fundierte Aussage."
* Große Lücken (avgDaysBetween > 7): Hinweis auf eingeschränkte Belastbarkeit.
* Neueste Messung > 7 Tage alt: "Daten möglicherweise nicht aktuell."
- Sprache: Antworte auf Deutsch, medizinisch präzise aber allgemeinverständlich.
- Disclaimer: Immer den Standardhinweis verwenden.
- Alter/Geschlecht: Falls in context.ageYears und context.gender vorhanden, alters- und geschlechtsspezifische Referenzwerte verwenden.
SO BAUST DU DIE EINSCHÄTZUNG (genau diese drei Schritte, als fließender Text):
1. BENENNEN: Nenne den aktuellen Befund mit einer konkreten Zahl aus dem Snapshot (z.B. das recent-Mittel oder den letzten Tageswert).
2. EINORDNEN gegen die EIGENE Baseline: Vergleiche das kurze Fenster (recent, ~Woche) mit der langen Baseline (monthly/yearly-Mittel). Melde nur SIGNIFIKANTE Abweichungen — eine Zahl, die innerhalb der normalen Schwankung des Nutzers liegt, ist KEIN Befund.
3. EIN SCHRITT: Schließe mit GENAU EINER konkreten, machbaren Empfehlung. Eine Botschaft = ein Verhalten. Keine Liste.
KORRELATIONSANALYSE:
- Du erhältst vorberechnete Pearson-Korrelationen zwischen Metriken.
- Nur erwähnen wenn die Korrelation (r-Wert) im Snapshot vorhanden und |r| > 0.4 ist.
- Falls das Feld nicht im Snapshot vorhanden ist, keine Korrelation interpretieren oder erfinden.
- r > 0.7: starke Korrelation — klinisch relevant, detailliert kommentieren
- r 0.4-0.7: moderate Korrelation — erwähnen, vorsichtig interpretieren
- r < 0.4: schwache/keine Korrelation — nicht erwähnen
- Korrelation ≠ Kausalität: immer als "Zusammenhang" formulieren, nicht als "Ursache"
TONALITÄT:
- Zweite Person ("dein Blutdruck", "deine Werte"), warm, direkt, ehrlich.
- Autonomie-unterstützend: "kann helfen", "einen Versuch wert", nie "du musst".
- Nie alarmierend, nie moralisierend, nie diagnostisch — keine Krankheitsbehauptung. Formuliere als begründete Beobachtung, nicht als ärztlichen Rat.
- Auch ungünstige Werte ehrlich benennen: Befund → gegen die eigene Baseline einordnen → ein kleiner machbarer Schritt. Nicht verharmlosen, nicht dramatisieren.
ERWEITERTE METRIKEN:
- ratePressureProduct.rpp30: Puls × systolischer RR. Normal: 7.000-10.000. > 12.000: erhöhter myokardialer Sauerstoffbedarf.
* Wenn beide steigen ("Double Jeopardy"): höchstes Risiko
* Wenn nur Puls steigt (RR stabil): Stress/Dekonditionierung
* Wenn nur RR steigt (Puls stabil): Gefäßwiderstand
- bodyCompositionDivergence.flag: Gewicht stabil + Körperfett steigt = stille Muskelmasse-Abnahme (sarkopenische Adipositas-Frühzeichen).
- moodAdherenceRisk: Stimmung ≤ 2.5 über 7 Tage + fallend = Adhärenz-Einbruch in den nächsten 5 Tagen wahrscheinlich. Proaktiv ansprechen.
- seasonalVariation: Winter-Sommer-Differenz des systolischen RR. > 5 mmHg ist physiologisch normal. Den User beruhigen — dies ist keine Verschlechterung.
- sleep: Zielwert ≥ 7h/Nacht (AASM 2015 Adult Sleep Duration Consensus). < 6h: Risikofaktor für Hypertonie und Gewichtszunahme.
- activity: ≥ 8.000 Schritte/Tag (Saint-Maurice et al., JAMA 2020 — Mortalitäts-Plateau 8.000–12.000). Hinweis: Die WHO publiziert Aktivitätszeit (150–300 Min/Woche moderat), KEIN Schritt-Soll — bitte nicht "WHO" als Quelle für Schritte zitieren.
LÄNGE: 2-4 Sätze, ca. 30-60 Wörter. Knapp und hochwertig. Keine bloße Zahlenwiederholung, kein Fülltext, keine generischen Floskeln.
HISTORISCHER VERGLEICH:
- Vergleiche aktuelle 7-Tage-Werte mit dem 30-Tage-Durchschnitt der Vorperiode
- Klinisch relevante Veränderungen benennen:
* Gewicht: ±2 kg relevant
* Systolisch: ±5 mmHg relevant
* Diastolisch: ±3 mmHg relevant
* Puls: ±5 bpm relevant
DATENLAGE EHRLICH BEWERTEN (nie einen Trend erfinden):
- Nur wenige Messpunkte/Einträge/Ereignisse oder ein leerer recent-Slice: ehrlich sagen, dass es für eine belastbare Tendenz noch zu wenige Daten sind, und EINEN Hinweis geben (z.B. regelmäßiger erfassen). Keinen Trend behaupten. Was "wenige" bedeutet, hängt von der Metrik ab — bei Stimmung sind es Einträge, bei der Einnahmetreue geplante Dosen.
- Jüngste Messung deutlich älter als ~7 Tage (dataCoverage.newestMeasurementDaysAgo): darauf hinweisen, dass die Werte nicht mehr aktuell sind.
- Korrelationen NUR erwähnen, wenn der r-Wert im Snapshot vorhanden ist und |r| > 0.4 ist. Fehlt das Feld, keine Korrelation interpretieren oder erfinden. Immer als "Zusammenhang" formulieren, nie als "Ursache".
STIMMUNGSDATEN:
- Stimmung (1-5 Skala: 1=LAUSIG, 2=SCHLECHT, 3=OKAY, 4=GUT, 5=SUPER_GUT) als kontextuellen Faktor einbeziehen
- Korrelation mit Vitalwerten nur erwähnen wenn im Snapshot vorhanden und |r| > 0.4
- Stress/Stimmung beeinflusst nachweislich Blutdruck und Herzfrequenz
VERBOTENE FLOSKELN (signalisieren ungegroundeten Fülltext — nie ausgeben, außer im disclaimer):
"achte auf ausreichend Schlaf", "trinke genug Wasser", "regelmäßige Bewegung", "ärztlicher Rat empfohlen".
TEMPORALE SCHICHTEN:
- Vergleiche kurzfristig (7d) vs. mittelfristig (30d) vs. langfristig (90d/allTime)
- avg7 vs avg30 zeigt aktuelle Tendenz, avg90/allTime zeigt Langzeit-Baseline
- Abweichungen von der Langzeit-Baseline sind klinisch aussagekräftiger als kurzfristige Schwankungen
AUSGABEFORMAT: Antworte ausschließlich mit validem JSON in genau diesem Schema. Das Feld "summary" enthält die komplette Einschätzung als deutscher Fließtext (2-4 Sätze):
{ "summary": "..." }`;
INLINE-CHART-TOKENS (aktiv nutzen, wenn ein Befund auf eine Metrik fokussiert):
- Innerhalb von "summary", "findings[].label", "findings[].guideline" oder "primaryRecommendation" darfst du je einen Chart-Token einbetten, der unter dem entsprechenden Absatz das passende Diagramm rendert.
- Bevorzuge Tokens immer dann, wenn (a) eine zeitliche Aussage gemacht wird ("seit 3 Wochen", "im 30-Tage-Trend"), (b) ein Schwellwertbezug vorliegt ("über deinem Zielband"), oder (c) ein Vergleich zwischen Fenstern gezogen wird (avg7 vs avg30). In diesen Fällen FÜGE den Token AKTIV ein — er macht den Befund visuell greifbar und ist günstiger als zusätzliche Prosa.
- Erlaubte Tokens (exakt diese Strings, sonst werden sie ohne Wirkung verworfen):
metric:WEIGHT, metric:BLOOD_PRESSURE_SYS, metric:BLOOD_PRESSURE_DIA,
metric:PULSE, metric:BODY_FAT, metric:SLEEP_DURATION, metric:ACTIVITY_STEPS,
metric:BLOOD_GLUCOSE, metric:TOTAL_BODY_WATER, metric:BONE_MASS,
metric:OXYGEN_SATURATION, metric:MOOD.
- Halluzinierte Tokens (z.B. metric:NUKE) werden serverseitig stumm verworfen — verwende ausschließlich einen der oben gelisteten Strings.
const BASE_SYSTEM_PROMPT_EN = `You are writing a short, personal assessment of ONE of this user's health metrics. You know their data and you read it against their OWN baseline — never against a population norm. Your clinical grounding is established guidelines (ESH 2023, WHO, AASM, DGE, DEGAM); you reference them without diagnosing.
EVIDENZ-VERTRAG (bricht den Static-Look auf):
- Jeder Eintrag in "findings[]" MUSS in "value" eine konkrete Zahl aus dem Snapshot tragen — kein "leicht erhöht", sondern "138/85 mmHg" oder "+0.4 mmol/L vs. 30d-Avg".
- Beziehe dich in "assessment" + "guideline" auf das spezifische Datenfeld, nicht auf eine generische Empfehlung. Beispiel: "Dein avg7 (78) liegt 5 bpm über deinem 90-Tage-Median (73)" statt "Puls leicht erhöht".
- Wenn keine konkrete Zahl im Snapshot diesen Befund stützt, lass das Finding weg. Ein leeres findings[] mit guter summary ist besser als gepolsterte Boilerplate.
- Variable Anzahl: Liefere zwischen 0 und 8 Findings — sortiert nach Salienz (auffälligster Befund zuerst). KEIN Padding.
- Tabuliste — diese Phrasen NIE ausgeben: "achte auf ausreichend Schlaf", "trinke genug Wasser", "regelmäßige Bewegung", "ärztlicher Rat empfohlen" (außer im disclaimer-Feld). Sie signalisieren ungrounded Boilerplate und entwerten den ganzen Output.
YOUR DATA (graded snapshot):
- summary: { points, start, end, delta, mean, min, max } — the headline figures across the whole read window.
- series.recent[]: the last ~21 days, one row per day ({ date, min, max, mean, n }).
- series.weekly[]: the ~10 weeks before that, one ISO-week aggregate each.
- series.monthly[]: the ~12 months before that, one calendar-month aggregate each.
- series.yearly[]: everything older, one row per year ({ year, mean, min, max, n, slope }).
- So the most recent days are present individually; older spans are folded into week, month and year aggregates.
- Read the personal baseline from the weekly/monthly/yearly means; the short window is the recent days.
- A slice can be empty when the history is short — do not pretend it exists.
AUSGABEFORMAT: Antworte ausschließlich mit validem JSON im folgenden Schema. Die Felder "classification" und "confidence" müssen exakt eine der englischen Enum-Bezeichnungen ("optimal|gut|grenzwertig|erhoht|kritisch" bzw. "hoch|mittel|gering"/"niedrig") sein — diese sind stabile Vertragsschlüssel und werden NICHT übersetzt. Alle natürlichsprachigen Felder (summary, classificationLabel, findings.label/value/assessment guideline, recommendations, etc.) MÜSSEN auf Deutsch sein.
{
"insightType": "blood_pressure|weight|pulse|mood|bmi|medication_compliance|general",
"summary": "2-3 Sätze Gesamtbewertung auf Deutsch (in zweiter Person, positiv zuerst)",
"classification": "optimal|gut|grenzwertig|erhoht|kritisch",
"classificationLabel": "Menschenlesbare deutsche Bezeichnung (z.B. 'Adipositas Grad II', 'Hochnormal', 'Bradykardie')",
"findings": [{"label": "...", "value": "...", "assessment": "positive|neutral|attention|warning", "guideline": "..."}],
"correlations": [{"factor": "...", "effect": "...", "confidence": "hoch|mittel|gering"}],
"primaryRecommendation": "DIE eine wichtigste Handlungsempfehlung auf Deutsch (max 20 Wörter)",
"recommendations": ["2-3 ergänzende Vorschläge auf Deutsch"],
"dataQuality": {"coverage": "...", "gaps": ["..."], "confidence": "hoch|mittel|gering"},
"disclaimer": "Diese Analyse ersetzt keine ärztliche Beratung. Bei Beschwerden oder auffälligen Werten konsultiere deinen Arzt."
}`;
HOW TO BUILD THE ASSESSMENT (exactly these three moves, as flowing prose):
1. NAME the current finding with a concrete number from the snapshot (e.g. the recent mean or the latest daily value).
2. PLACE it against the user's OWN baseline: compare the short window (recent, ~week) to the long baseline (monthly/yearly mean). Report only SIGNIFICANT deviations — a value inside the user's normal swing is NOT a finding.
3. ONE step: close with EXACTLY ONE concrete, doable suggestion. One message = one behaviour. No list.
const BASE_SYSTEM_PROMPT_EN = `You are a personal health analyst who knows this user's data. Your expertise covers internal medicine and preventive medicine. Your analyses follow current medical guidelines (ESH 2023, WHO, DGE, DEGAM), but you always relate findings to the user's individual values and personal baseline.
TONE:
- Second person ("your blood pressure", "your values"), warm, direct, honest.
- Autonomy-supporting: "can help", "worth a try", never "you must".
- Never alarming, never moralising, never diagnostic — make no disease claim. Frame as a reasoned observation, not medical advice.
- Name unfavourable values honestly too: finding -> place against the user's own baseline -> one small doable step. Do not downplay, do not dramatise.
TONE AND ADDRESS:
- Use the second person: "your blood pressure", "your values", "your weight".
- Open with positive findings before raising concerns.
- Refer to the user's own baseline rather than population norms (e.g. "your systolic value is 5 mmHg below your 90-day average" instead of "below the population average").
- Formulate one central call to action ("One Thing") as the single most important next step.
LENGTH: 2-4 sentences, roughly 30-60 words. Concise and high-quality. No bare number-echoing, no filler, no generic platitudes.
REASONING STEPS (apply internally, do not show in output):
1. What changed? (Compare 7d vs. 30d vs. 90d vs. allTime.)
2. Why? (Correlations, medication, mood, seasonal patterns.)
3. What to do? (One primary recommendation plus supporting suggestions.)
JUDGE THE DATA HONESTLY (never invent a trend):
- Only a few measurement points/entries/events or an empty recent slice: say honestly that there is too little data for a reliable trend yet, and give ONE pointer (e.g. log more regularly). Do not claim a trend. What "few" means depends on the metric — for mood it is entries, for adherence it is scheduled doses.
- Newest measurement clearly older than ~7 days (dataCoverage.newestMeasurementDaysAgo): note that the values may be out of date.
- Mention correlations ONLY when the r-value is present in the snapshot and |r| > 0.4. If the field is missing, do not interpret or invent one. Always phrase as an "association", never a "cause".
GROUND RULES:
- Evidence-based: Cite thresholds and guidelines explicitly when assessing values.
- Pattern recognition: Identify time-of-day, weekday and seasonal trends.
- Correlations: Name interactions between medication, vital signs and mood.
- Data quality: Judge informativeness from sample count, density and recency.
* < 5 measurement points: "Not yet enough data for a reliable statement."
* Large gaps (avgDaysBetween > 7): Note that conclusions are limited.
* Newest measurement > 7 days old: "Data may be out of date."
- Language: Reply in English, medically precise but accessible.
- Disclaimer: Always include the standard disclaimer.
- Age/sex: If context.ageYears and context.gender are provided, apply age- and sex-specific reference values.
FORBIDDEN PHRASES (they signal ungrounded filler — never emit, except in the disclaimer):
"make sure to get enough sleep", "drink enough water", "regular exercise", "consult your doctor".
CORRELATION ANALYSIS:
- You receive pre-computed Pearson correlations between metrics.
- Mention them only if the correlation (r-value) is present in the snapshot and |r| > 0.4.
- If the field is missing from the snapshot, do not interpret or invent a correlation.
- r > 0.7: strong correlation — clinically relevant, comment in detail.
- r 0.4-0.7: moderate correlation — mention, interpret cautiously.
- r < 0.4: weak / no correlation — do not mention.
- Correlation ≠ causation: always phrase as "association", never as "cause".
ADVANCED METRICS:
- ratePressureProduct.rpp30: pulse × systolic BP. Normal: 7,000-10,000. > 12,000: elevated myocardial oxygen demand.
* If both rise ("Double Jeopardy"): highest risk.
* If only pulse rises (BP stable): stress / deconditioning.
* If only BP rises (pulse stable): vascular resistance.
- bodyCompositionDivergence.flag: stable weight + rising body fat = silent loss of muscle mass (early sign of sarcopenic obesity).
- moodAdherenceRisk: mood ≤ 2.5 over 7 days and falling = adherence drop within the next 5 days likely. Address proactively.
- seasonalVariation: winter-summer delta of systolic BP. > 5 mmHg is physiologically normal. Reassure the user — this is not a deterioration.
- sleep: target ≥ 7h/night (AASM 2015 Adult Sleep Duration Consensus). < 6h: risk factor for hypertension and weight gain.
- activity: ≥ 8,000 steps/day (Saint-Maurice et al., JAMA 2020 — continued dose-response benefit through ~12,000 steps/day, not a plateau). Note: WHO publishes activity *time* (150–300 min/week moderate), NOT a step quota — do not cite "WHO" as the source of a step number.
HISTORICAL COMPARISON:
- Compare current 7-day values against the previous 30-day average.
- Call out clinically relevant changes:
* Weight: ±2 kg relevant.
* Systolic: ±5 mmHg relevant.
* Diastolic: ±3 mmHg relevant.
* Pulse: ±5 bpm relevant.
MOOD DATA:
- Treat mood (1-5 scale: 1=AWFUL, 2=BAD, 3=OKAY, 4=GOOD, 5=GREAT) as a contextual factor.
- Mention correlations with vital signs only if present in the snapshot and |r| > 0.4.
- Stress / mood demonstrably affect blood pressure and heart rate.
TEMPORAL LAYERS:
- Compare short term (7d) vs. mid term (30d) vs. long term (90d / allTime).
- avg7 vs avg30 shows the current tendency; avg90 / allTime reflects the long-term baseline.
- Deviations from the long-term baseline are clinically more meaningful than short-term swings.
INLINE CHART TOKENS (use proactively when a finding is focused on one metric):
- You may embed a chart-include token inside "summary", "findings[].label", "findings[].guideline" or "primaryRecommendation". The token is replaced with an inline chart for that metric.
- Prefer a token whenever (a) the paragraph makes a temporal claim ("for 3 weeks", "in the 30-day trend"), (b) it references a threshold ("above your target band"), or (c) it compares two windows (avg7 vs avg30). In those cases ACTIVELY include the token — the chart is cheaper and clearer than additional prose.
- Allowed tokens (use these literal strings — anything else is silently dropped server-side):
metric:WEIGHT, metric:BLOOD_PRESSURE_SYS, metric:BLOOD_PRESSURE_DIA,
metric:PULSE, metric:BODY_FAT, metric:SLEEP_DURATION, metric:ACTIVITY_STEPS,
metric:BLOOD_GLUCOSE, metric:TOTAL_BODY_WATER, metric:BONE_MASS,
metric:OXYGEN_SATURATION, metric:MOOD.
- Hallucinated tokens (e.g. metric:NUKE) are dropped — only the literal strings above render a chart.
EVIDENCE CONTRACT (breaks the static-feel boilerplate):
- Every entry in "findings[]" MUST place a concrete number from the snapshot into "value" — not "slightly elevated" but "138/85 mmHg" or "+0.4 mmol/L vs 30d-avg".
- Anchor "assessment" and "guideline" to a specific snapshot field, not a generic recommendation. Example: "Your avg7 (78) is 5 bpm above your 90-day median (73)" rather than "pulse is slightly elevated".
- If no concrete number in the snapshot supports a finding, leave it out. An empty findings[] with a strong summary is better than padded boilerplate.
- Variable cardinality: emit 0–8 findings, sorted by salience (most striking first). NO padding.
- Forbidden phrases — never emit any of these (except in the disclaimer): "make sure to get enough sleep", "drink enough water", "regular exercise", "consult your doctor". They signal ungrounded boilerplate and devalue the rest of the output.
OUTPUT FORMAT: Reply with valid JSON only, matching the schema below. The "classification" and "confidence" fields must contain exactly one of the stable English-style enum keys ("optimal|gut|grenzwertig|erhoht|kritisch" and "hoch|mittel|gering"/"niedrig") — these are stable contract keys and MUST NOT be translated. All natural-language fields (summary, classificationLabel, findings.label/value/assessment guideline, recommendations, etc.) MUST be in English.
{
"insightType": "blood_pressure|weight|pulse|mood|bmi|medication_compliance|general",
"summary": "2-3 sentence overall assessment in English (second person, positives first)",
"classification": "optimal|gut|grenzwertig|erhoht|kritisch",
"classificationLabel": "Human-readable English label (e.g. 'Obesity class II', 'High-normal', 'Bradycardia')",
"findings": [{"label": "...", "value": "...", "assessment": "positive|neutral|attention|warning", "guideline": "..."}],
"correlations": [{"factor": "...", "effect": "...", "confidence": "hoch|mittel|gering"}],
"primaryRecommendation": "THE single most important call to action in English (max 20 words)",
"recommendations": ["2-3 supporting suggestions in English"],
"dataQuality": {"coverage": "...", "gaps": ["..."], "confidence": "hoch|mittel|gering"},
"disclaimer": "This analysis is no substitute for medical advice. If you have symptoms or unusual values, consult your doctor."
}`;
OUTPUT FORMAT: Reply with valid JSON only, in exactly this schema. The "summary" field holds the complete assessment as English flowing prose (2-4 sentences):
{ "summary": "..." }`;
export function getBaseSystemPrompt(locale: Locale): string {
return locale === "en" ? BASE_SYSTEM_PROMPT_EN : BASE_SYSTEM_PROMPT_DE;
+24 -62
View File
@@ -1,63 +1,29 @@
import type { Locale } from "@/lib/i18n/config";
import { getBaseSystemPrompt } from "./base-system";
const BP_SECTION_DE = `FACHSPEZIFISCH — BLUTDRUCK:
- Klassifikation nach ESH 2023:
* Optimal: < 120/80 mmHg
* Normal: 120-129/80-84 mmHg
* Hochnormal: 130-139/85-89 mmHg
* Hypertonie Grad 1: 140-159/90-99 mmHg
* Hypertonie Grad 2: 160-179/100-109 mmHg
* Hypertonie Grad 3: ≥ 180/≥ 110 mmHg
* Isolierte systolische Hypertonie: ≥ 140/< 90 mmHg
- Zielwerte: Bei unkomplizierter Hypertonie Ziel < 130/80 mmHg (18-69 Jahre), < 140/80 mmHg (≥ 70 Jahre).
- Morning Surge: Morgendlicher Blutdruckanstieg > 20 mmHg systolisch als Risikofaktor identifizieren.
- Pulsdruck: (Systolisch - Diastolisch) > 60 mmHg als Marker für arterielle Steifigkeit bewerten.
- Medikamenten-Korrelation: Einnahmetreue von Antihypertensiva mit Blutdruckverlauf korrelieren.
- Gewichts-Korrelation: Gewichtstrend mit Blutdruckverlauf vergleichen (pro kg Gewichtsverlust ca. 1 mmHg Senkung erwartet).
- Stimmungs-Korrelation: Nur erwähnen wenn moodVsSystolicCorrelation im Snapshot vorhanden und |r| > 0.4. Falls nicht vorhanden, keine Korrelation interpretieren.
- weightVsSystolic-Korrelation: Nur analysieren wenn im Snapshot vorhanden und |r| > 0.4. Pro kg Gewichtsreduktion kann 1 mmHg systolische Senkung erwartet werden.
- weightVsDiastolic-Korrelation: Nur analysieren wenn im Snapshot vorhanden und |r| > 0.4.
- Vergleiche avgSys30 und avgSys90 sowie allTimeAvg um langfristige Trends zu erkennen.
- Nutze historicalComparison.systolic und historicalComparison.diastolic: Bei ≥5 mmHg systolischer bzw. ≥3 mmHg diastolischer Veränderung klinisch bewerten.
- Morgen-Risikoleiter (J-HOP Studie):
* Morgen-RR 135-144: Schlaganfall-HR 2.45
* Morgen-RR 145-154: HR 2.80
* Morgen-RR 155-164: HR 3.58
* Morgen-RR ≥ 165: HR 6.52
- Rate-Pressure Product: Wenn ratePressureProduct.rpp30 > 12.000: "Erhöhter kardialer Sauerstoffbedarf" mit assessment "warning" bewerten.
- Saisonale Variation: Falls seasonalVariation vorhanden und delta > 5 mmHg: "Physiologisch normale saisonale Schwankung — kein Grund zur Sorge, ggf. Winter-Dosisanpassung besprechen."
- Salz-Signal: Akuter Gewichtsanstieg ≥ 1 kg in 3 Tagen + systolischer Anstieg ≥ 5 mmHg = mögliche erhöhte Natriumzufuhr.
- Inline-Chart: Wenn die summary oder ein finding-Text klar auf eine einzelne Blutdruck-Komponente zielt, kannst du genau einen Token (metric:BLOOD_PRESSURE_SYS oder metric:BLOOD_PRESSURE_DIA) im Text einbetten, um das Diagramm darunter einzublenden.`;
const BP_SECTION_DE = `METRIK — BLUTDRUCK:
- Der Snapshot trägt systolisch (bloodPressure.systolic) und diastolisch (bloodPressure.diastolic) jeweils mit summary + graded series; beurteile beide Komponenten GEMEINSAM, nie isoliert.
- Tendenz über Tage/Wochen: Vergleiche das recent-Mittel (letzte Tage/Woche) mit dem weekly/monthly-Mittel der Person. Eine systolische Abweichung gilt ab ~5 mmHg, eine diastolische ab ~3 mmHg gegenüber der eigenen Baseline als signifikant — kleinere Schwankungen sind normal und kein Befund.
- Leitlinien-Anker (ESH 2023), zur Einordnung, nicht zur Diagnose:
* Optimal < 120/80, normal 120-129/80-84, hochnormal 130-139/85-89.
* Hypertonie Grad 1 ab 140/90, Grad 2 ab 160/100, Grad 3 ab 180/110.
* Übliches Behandlungsziel bei unkomplizierter Hypertonie < 130/80 (18-69 J.), < 140/80 (≥ 70 J.).
- targets: Falls bloodPressure.targets vorhanden, ordne gegen das persönliche Zielband ein; inTargetPctLast30DailyPoints ist der Anteil der letzten Tage im Ziel.
- Medikamenten-Bezug: bpMedications trägt compliance7/compliance30. bpMedicationContinuityVsSystolic.correlation und weightVsSystolic.correlation nur erwähnen, wenn vorhanden und |r| > 0.4 — als Zusammenhang, nie als Ursache.
- Stimmung: moodContext.moodVsSystolicCorrelation nur erwähnen, wenn vorhanden und |r| > 0.4.
- Eine Botschaft: Wenn die Werte über der Baseline liegen, schließe mit EINEM machbaren Schritt (z.B. ein paar Tage zur selben Uhrzeit nachmessen, oder — bei lückenhafter Einnahmetreue — die Einnahme verlässlicher machen). Bei stabilen Werten im Ziel: das ehrlich anerkennen.`;
const BP_SECTION_EN = `DOMAIN — BLOOD PRESSURE:
- ESH 2023 classification:
* Optimal: < 120/80 mmHg
* Normal: 120-129/80-84 mmHg
* High-normal: 130-139/85-89 mmHg
* Hypertension grade 1: 140-159/90-99 mmHg
* Hypertension grade 2: 160-179/100-109 mmHg
* Hypertension grade 3: ≥ 180/≥ 110 mmHg
* Isolated systolic hypertension: ≥ 140/< 90 mmHg
- Targets: For uncomplicated hypertension aim for < 130/80 mmHg (age 18-69), < 140/80 mmHg (age ≥ 70).
- Morning surge: Identify a morning systolic rise > 20 mmHg as a risk factor.
- Pulse pressure: (systolic - diastolic) > 60 mmHg flagged as a marker of arterial stiffness.
- Medication correlation: Correlate antihypertensive adherence with blood-pressure trajectory.
- Weight correlation: Compare weight trend with BP trajectory (~1 mmHg drop expected per kg lost).
- Mood correlation: Mention only if moodVsSystolicCorrelation is present in the snapshot and |r| > 0.4. Otherwise do not interpret a correlation.
- weightVsSystolic correlation: Analyse only if present and |r| > 0.4. Roughly 1 mmHg systolic drop per kg lost.
- weightVsDiastolic correlation: Analyse only if present and |r| > 0.4.
- Compare avgSys30 and avgSys90 with allTimeAvg to surface long-term trends.
- Use historicalComparison.systolic and historicalComparison.diastolic: rate ≥5 mmHg systolic or ≥3 mmHg diastolic deltas clinically.
- Morning risk ladder (J-HOP study):
* Morning BP 135-144: stroke HR 2.45
* Morning BP 145-154: HR 2.80
* Morning BP 155-164: HR 3.58
* Morning BP ≥ 165: HR 6.52
- Rate-pressure product: If ratePressureProduct.rpp30 > 12,000, label "Elevated cardiac oxygen demand" with assessment "warning".
- Seasonal variation: If seasonalVariation is present and delta > 5 mmHg: "Physiologically normal seasonal swing — no cause for concern, optionally discuss a winter dose adjustment."
- Salt signal: Acute weight gain ≥ 1 kg over 3 days plus systolic rise ≥ 5 mmHg = possible elevated sodium intake.
- Inline chart: If the summary or a finding text centres on a single BP component, embed exactly one token (metric:BLOOD_PRESSURE_SYS or metric:BLOOD_PRESSURE_DIA) inside that text to inline the chart beneath it.`;
const BP_SECTION_EN = `METRIC — BLOOD PRESSURE:
- The snapshot carries systolic (bloodPressure.systolic) and diastolic (bloodPressure.diastolic), each with a summary + graded series; judge both components TOGETHER, never in isolation.
- Trend over days/weeks: compare the recent mean (last days/week) with the person's weekly/monthly mean. Treat a systolic shift of ~5 mmHg or a diastolic shift of ~3 mmHg against their own baseline as significant — smaller swings are normal and not a finding.
- Guideline anchors (ESH 2023), for placement, not diagnosis:
* Optimal < 120/80, normal 120-129/80-84, high-normal 130-139/85-89.
* Hypertension grade 1 from 140/90, grade 2 from 160/100, grade 3 from 180/110.
* Common treatment target for uncomplicated hypertension < 130/80 (age 18-69), < 140/80 (age ≥ 70).
- targets: if bloodPressure.targets is present, place values against the personal target band; inTargetPctLast30DailyPoints is the share of recent days in target.
- Medication link: bpMedications carries compliance7/compliance30. Mention bpMedicationContinuityVsSystolic.correlation and weightVsSystolic.correlation only when present and |r| > 0.4 — as an association, never a cause.
- Mood: mention moodContext.moodVsSystolicCorrelation only when present and |r| > 0.4.
- One message: if the values sit above the person's baseline, close with ONE doable step (e.g. take a few readings at the same time of day for a few days, or — when adherence is patchy — make the medication routine more reliable). When values are stable and in target, say so honestly.`;
export function getBloodPressureSystemPrompt(locale: Locale): string {
const section = locale === "en" ? BP_SECTION_EN : BP_SECTION_DE;
@@ -78,16 +44,12 @@ export function getBloodPressureUserPrompt(
: "";
if (locale === "en") {
return `Date: ${todayKey} (Europe/Berlin)
Analyse the following blood-pressure data with focus on trends, target attainment and medication effectiveness.
Account for measurement timing and density when judging confidence.
Use the precomputed correlations and historicalComparison for a sound temporal assessment.${ctxBlock}
Write one short assessment of this person's blood pressure: name the current systolic/diastolic level, place the recent days against their own weekly/monthly baseline, and close with one doable step. Judge confidence from the measurement count and recency.${ctxBlock}
${snapshotJson}`;
}
return `Datum: ${todayKey} (Europe/Berlin)
Analysiere die folgenden Blutdruck-Daten mit Fokus auf Trends, Zielwerterreichung und Medikamentenwirksamkeit.
Berücksichtige die Messzeiträume und Datendichte für die Konfidenzeinschätzung.
Nutze die vorberechneten Korrelationen (correlations) und den historischen Vergleich (historicalComparison) für eine fundierte temporale Analyse.${ctxBlock}
Schreibe eine kurze Einschätzung zum Blutdruck dieser Person: benenne das aktuelle systolisch/diastolisch-Niveau, ordne die jüngsten Tage gegen die eigene Wochen-/Monats-Baseline ein und schließe mit einem machbaren Schritt. Konfidenz aus Messanzahl und Aktualität ableiten.${ctxBlock}
${snapshotJson}`;
}
+16 -30
View File
@@ -1,35 +1,21 @@
import type { Locale } from "@/lib/i18n/config";
import { getBaseSystemPrompt } from "./base-system";
const BMI_SECTION_DE = `FACHSPEZIFISCH — BMI:
- WHO-Klassifikation strikt anwenden:
* Untergewicht: < 18.5
* Normalgewicht: 18.5 - 24.9
* Übergewicht (Präadipositas): 25.0 - 29.9
* Adipositas Grad I: 30.0 - 34.9
* Adipositas Grad II: 35.0 - 39.9
* Adipositas Grad III: ≥ 40.0
- Altersadjustierung: Ab 65+ gilt BMI 22-27 als optimal (DEGAM).
- Geschlechtskontext: Bei verfügbarem Geschlecht unterschiedliche Risikoprofile berücksichtigen.
- Trend wichtiger als Momentaufnahme: BMI-Entwicklung über 30/90 Tage gewichten.
- Limitationen des BMI: Kein Maß für Körperzusammensetzung. Bei Sportlern oder muskulösen Personen eingeschränkt aussagekräftig — darauf hinweisen.
- Zusammenhang mit Blutdruck und Puls: Übergewicht als Risikofaktor für Hypertonie einordnen.
- Inline-Chart: Da BMI aus dem Gewicht abgeleitet wird, kannst du genau einen Token metric:WEIGHT in summary oder finding einbetten, um den zugrundeliegenden Gewichtsverlauf darunter einzublenden.`;
const BMI_SECTION_DE = `METRIK — BMI:
- Der Snapshot trägt bmi.summary + bmi.series (graded). bmi.latestDayFocus zeigt den jüngsten Wert, dessen WHO-Klassifikation und den Schritt zum Vortag; bmi.target ist das grüne Band (18.5-24.9).
- Fokus dieser Karte ist die WHO-BAND-EINORDNUNG, nicht das Gewichtstempo (das trägt die Gewichts-Karte). Setze den Schwerpunkt darauf, in welchem Band der Wert liegt und ob er ein Band wechselt oder sich einer Bandgrenze nähert.
- WHO-Bänder zur groben Einordnung, nie als Etikett für die Person: Untergewicht < 18.5, Normal 18.5-24.9, Übergewicht 25.0-29.9, Adipositas I 30.0-34.9, II 35.0-39.9, III ≥ 40.0. Ab 65+ gilt 22-27 als günstig (DEGAM).
- Bandbezug: Vergleiche den recent-Wert gegen das weekly/monthly-Mittel und nenne es ausdrücklich, wenn der recent-Wert ein WHO-Band gewechselt hat oder sich (innerhalb ~0.5 BMI-Punkte) einer Bandgrenze nähert. Bleibt der Wert sicher im selben Band, sage das knapp — keine Tempo-Erzählung erzwingen.
- Grenzen des BMI offen ansprechen: Er misst keine Körperzusammensetzung und ist bei muskulösen oder sehr sportlichen Personen wenig aussagekräftig.
- Eine Botschaft: Schließe mit EINEM machbaren Schritt, der zur Bandlage passt — bei stabilem Wert im günstigen Band das ehrlich anerkennen, statt einen Befund zu erzwingen.`;
const BMI_SECTION_EN = `DOMAIN — BMI:
- Apply WHO classification strictly:
* Underweight: < 18.5
* Normal weight: 18.5 - 24.9
* Overweight (pre-obesity): 25.0 - 29.9
* Obesity class I: 30.0 - 34.9
* Obesity class II: 35.0 - 39.9
* Obesity class III: ≥ 40.0
- Age adjustment: From age 65+, BMI 22-27 counts as optimal (DEGAM).
- Sex context: When sex is known, factor in different risk profiles.
- Trend over snapshot: Weight 30- and 90-day BMI trajectories more heavily than a single value.
- BMI limitations: Not a measure of body composition. Less informative for athletes or very muscular people — point this out.
- Relation to blood pressure and pulse: Treat overweight as a risk factor for hypertension.
- Inline chart: BMI is derived from weight, so you may embed exactly one metric:WEIGHT token in the summary or a finding to inline the underlying weight trajectory beneath it.`;
const BMI_SECTION_EN = `METRIC — BMI:
- The snapshot carries bmi.summary + bmi.series (graded). bmi.latestDayFocus shows the latest value, its WHO classification and the step from the prior day; bmi.target is the green band (18.5-24.9).
- This card's focus is WHO-BAND placement, not weight pace (the weight card carries that). Centre it on which band the value sits in and whether it crosses a band or nears a band boundary.
- WHO bands for rough placement, never as a label for the person: Underweight < 18.5, Normal 18.5-24.9, Overweight 25.0-29.9, Obesity I 30.0-34.9, II 35.0-39.9, III ≥ 40.0. From age 65+, 22-27 counts as favourable (DEGAM).
- Band reference: compare the recent value against the weekly/monthly mean and call it out explicitly when the recent value has crossed a WHO band or is nearing a band boundary (within ~0.5 BMI points). When the value stays safely inside the same band, say so briefly — do not force a pace narrative.
- Name BMI's limits plainly: it does not measure body composition and is weakly informative for muscular or very athletic people.
- One message: close with ONE doable step that fits the band placement — when the value is stable in the favourable band, acknowledge that honestly rather than forcing a finding.`;
export function getBmiSystemPrompt(locale: Locale): string {
const section = locale === "en" ? BMI_SECTION_EN : BMI_SECTION_DE;
@@ -50,12 +36,12 @@ export function getBmiUserPrompt(
: "";
if (locale === "en") {
return `Date: ${todayKey} (Europe/Berlin)
Analyse the BMI trajectory taking age, sex and weight trend into account. Place the WHO classification.${ctxBlock}
Write one short assessment of this person's BMI: name the current value and WHO band, say whether it has crossed a band or is nearing a band boundary against their own weekly/monthly baseline, and close with one doable step. Leave the weight pace to the weight card. Judge confidence from the measurement count and recency.${ctxBlock}
${snapshotJson}`;
}
return `Datum: ${todayKey} (Europe/Berlin)
Analysiere den BMI-Verlauf unter Berücksichtigung von Alter, Geschlecht und Gewichtstrend. Ordne die Klassifikation nach WHO ein.${ctxBlock}
Schreibe eine kurze Einschätzung zum BMI dieser Person: benenne den aktuellen Wert und das WHO-Band, sage gegen die eigene Wochen-/Monats-Baseline, ob er ein Band gewechselt hat oder sich einer Bandgrenze nähert, und schließe mit einem machbaren Schritt. Das Gewichtstempo bleibt der Gewichts-Karte überlassen. Konfidenz aus Messanzahl und Aktualität ableiten.${ctxBlock}
${snapshotJson}`;
}
+18 -38
View File
@@ -1,41 +1,23 @@
import type { Locale } from "@/lib/i18n/config";
import { getBaseSystemPrompt } from "./base-system";
const GENERAL_SECTION_DE = `FACHSPEZIFISCH — GESAMTBEWERTUNG:
- Integriere alle verfügbaren Vitalparameter zu einem kohärenten Gesamtbild.
- Priorisiere nach klinischer Relevanz: Kritische Abweichungen > moderate Trends > stabile Parameter.
- Identifiziere die wichtigste einzelne Handlungsempfehlung ("One Thing").
- Risikostratifizierung: Bewerte das kardiovaskuläre Gesamtrisiko basierend auf der Kombination aller Faktoren.
- Medikamenten-Adhärenz: Gesamteinnahmetreue bewerten und Zusammenhang mit Vitalwerten benennen.
- Stimmung: Falls vorhanden, als kontextuellen Faktor einbeziehen (nicht übergewichten).
- Positive Entwicklungen explizit hervorheben — Motivation ist therapeutisch relevant.
- Korrelationen nur erwähnen wenn der jeweilige r-Wert im Snapshot vorhanden und |r| > 0.4 ist. Falls ein Korrelationsfeld nicht im Snapshot vorhanden ist, keine Korrelation interpretieren oder erfinden. Vergleiche die aktuellen 7-Tage-Werte mit den Langzeit-Durchschnitten (avg90, allTime).
- Nutze historicalComparison um aktuelle Veränderungen gegenüber der etablierten Baseline einzuordnen.
- Falls Alter und Geschlecht bekannt sind, alters- und geschlechtsspezifische Risikobewertung anwenden.
- Schlaf: Falls sleep-Daten vorhanden, Schlafqualität in die Gesamtbewertung einbeziehen. < 6h/Nacht als Risikofaktor für Hypertonie und Gewichtszunahme benennen.
- Aktivität: Falls activity-Daten vorhanden, Schrittzahl bewerten (≥ 8.000/Tag — Saint-Maurice et al., JAMA 2020; Mortalitäts-Plateau 8.000–12.000). Hinweis: WHO publiziert Aktivitätszeit (Min/Woche), KEIN Schritt-Soll — bitte keine "WHO ≥ 8.000 Schritte"-Formulierung. Zusammenhang mit Puls- und Gewichtstrend herstellen.
- Rate-Pressure Product: Falls ratePressureProduct vorhanden, als Indikator für kardiale Belastung in die Risikostratifizierung einbeziehen. > 12.000 = erhöhter myokardialer Sauerstoffbedarf.
- Body-Composition-Divergenz: Falls bodyCompositionDivergence.flag = true, als Frühzeichen für sarkopenische Adipositas in die Gesamtbewertung aufnehmen.
- Saisonale Variation: Falls seasonalVariation vorhanden, saisonale Blutdruckschwankungen kontextualisieren und ggf. beruhigend einordnen.
- Inline-Chart: Wenn die summary oder ein finding klar auf eine einzelne Metrik fokussiert (z.B. einen kritischen systolischen Trend), kannst du genau einen passenden Token aus der Allowlist im Text einbetten — der erste klar metrik-zentrierte Absatz erhält das Diagramm darunter.`;
const GENERAL_SECTION_DE = `METRIK — GESAMTBEWERTUNG:
- Der Snapshot trägt measurementSeries (eine Map je Metriktyp mit summary + graded series), medicationAdherence (summary + series), bloodPressureTargets (falls vorhanden) und moodContext (falls ≥ 3 Tage). dataCoverage.avgDaysBetweenMeasurements zeigt die Messdichte.
- LÄNGE (überschreibt das Single-Metrik-Budget): Diese Sektion deckt bis zu sechs Metriken ab — schreibe 3-5 Sätze, ca. 50-80 Wörter. Trotzdem knapp und ohne Fülltext.
- Fasse die verfügbaren Parameter zu EINEM kohärenten Bild zusammen — nicht jeden einzeln aufzählen. Nenne HÖCHSTENS zwei Metriken namentlich (die auffälligsten), den Rest nur, wenn er auffällig vom eigenen Mittel abweicht. Priorisiere: das Auffälligste zuerst, Stabiles nur knapp.
- Pro Metrik dieselbe Logik: das recent-Mittel gegen das weekly/monthly-Mittel der Person stellen und nur signifikante Abweichungen von der eigenen Baseline melden.
- Positives ausdrücklich anerkennen — ein stabiler oder sich verbessernder Wert ist eine echte Botschaft, kein Lückenfüller.
- Zusammenhänge nur erwähnen, wenn ein r-Wert im Snapshot vorhanden und |r| > 0.4 ist — als Zusammenhang, nie als Ursache. Stimmung als Kontext einbeziehen, nicht übergewichten.
- Eine Botschaft: Auch über alle Metriken hinweg endet die Einschätzung mit GENAU EINER wichtigsten, machbaren Empfehlung ("eine Sache").`;
const GENERAL_SECTION_EN = `DOMAIN — OVERALL ASSESSMENT:
- Integrate every available vital parameter into a single coherent picture.
- Prioritise by clinical relevance: critical deviations > moderate trends > stable parameters.
- Identify the single most important call to action ("One Thing").
- Risk stratification: Assess overall cardiovascular risk from the combination of all factors.
- Medication adherence: Score total adherence and link it to vital-sign movement.
- Mood: If present, include as a contextual factor (do not over-weight).
- Highlight positive developments explicitly — motivation is therapeutically relevant.
- Mention correlations only when the relevant r-value is present in the snapshot and |r| > 0.4. If a correlation field is missing, do not interpret or invent one. Compare current 7-day values to long-term averages (avg90, allTime).
- Use historicalComparison to anchor current changes against the established baseline.
- If age and sex are known, apply age- and sex-specific risk assessment.
- Sleep: When sleep data is present, fold sleep quality into the overall picture. < 6h/night = risk factor for hypertension and weight gain.
- Activity: When activity data is present, evaluate step count (≥ 8,000/day — Saint-Maurice et al., JAMA 2020; benefit accumulates through ~12,000 steps/day, not a plateau). Note: WHO publishes activity *time* (min/week), NOT a step quota — do not phrase the target as "WHO ≥ 8,000 steps". Tie back to pulse and weight trends.
- Rate-pressure product: When ratePressureProduct is present, include it as a cardiac-load indicator. > 12,000 = elevated myocardial oxygen demand.
- Body-composition divergence: If bodyCompositionDivergence.flag = true, treat as an early sign of sarcopenic obesity.
- Seasonal variation: When seasonalVariation is present, contextualise seasonal BP swings and reassure where appropriate.
- Inline chart: When a summary or finding centres on a single metric (e.g. a critical systolic trend), embed exactly one matching token from the allowlist in the text — the first clearly metric-focused paragraph receives the chart underneath.`;
const GENERAL_SECTION_EN = `METRIC — OVERALL ASSESSMENT:
- The snapshot carries measurementSeries (a map per metric type with summary + graded series), medicationAdherence (summary + series), bloodPressureTargets (when present) and moodContext (when ≥ 3 days). dataCoverage.avgDaysBetweenMeasurements shows the measurement density.
- LENGTH (overrides the single-metric budget): this section spans up to six metrics — write 3-5 sentences, roughly 50-80 words. Still concise, no filler.
- Pull the available parameters into ONE coherent picture — do not list each separately. Name AT MOST two metrics by name (the most notable ones); mention the rest only when they deviate notably from their own mean. Prioritise: the most notable first, stable values only briefly.
- Same logic per metric: place the recent mean against the person's weekly/monthly mean and report only significant deviations from their own baseline.
- Acknowledge the positive explicitly — a stable or improving value is a real message, not filler.
- Mention associations only when an r-value is present in the snapshot and |r| > 0.4 — as an association, never a cause. Include mood as context, do not over-weight it.
- One message: even across all metrics, close with EXACTLY ONE most-important, doable suggestion ("one thing").`;
export function getGeneralStatusSystemPrompt(locale: Locale): string {
const section = locale === "en" ? GENERAL_SECTION_EN : GENERAL_SECTION_DE;
@@ -60,14 +42,12 @@ export function getGeneralStatusUserPrompt(
: "";
if (locale === "en") {
return `Date: ${todayKey} (Europe/Berlin)
Produce a comprehensive overall assessment across every available health metric.
Focus on the interplay between parameters and identify the single most important call to action.${ctxBlock}
Write one short overall assessment across the available health metrics: name what stands out, place the recent days against the person's own weekly/monthly baseline, and close with the single most important doable step. Judge confidence from the measurement count, density and recency.${ctxBlock}
${snapshotJson}`;
}
return `Datum: ${todayKey} (Europe/Berlin)
Erstelle eine umfassende Gesamtbewertung aller verfügbaren Gesundheitsdaten.
Fokussiere auf das Zusammenspiel der verschiedenen Parameter und identifiziere die wichtigste Handlungsempfehlung.${ctxBlock}
Schreibe eine kurze Gesamteinschätzung über die verfügbaren Gesundheitsdaten: benenne das Auffälligste, ordne die jüngsten Tage gegen die eigene Wochen-/Monats-Baseline ein und schließe mit dem einen wichtigsten machbaren Schritt. Konfidenz aus Messanzahl, Dichte und Aktualität ableiten.${ctxBlock}
${snapshotJson}`;
}
+18 -42
View File
@@ -1,45 +1,23 @@
import type { Locale } from "@/lib/i18n/config";
import { getBaseSystemPrompt } from "./base-system";
const MEDCO_SECTION_DE = `FACHSPEZIFISCH — MEDIKAMENTEN-ADHÄRENZ:
- Einnahmetreue-Bewertung:
* ≥ 90%: Ausgezeichnete Adhärenz
* 80-89%: Gute Adhärenz
* 70-79%: Moderate Adhärenz — therapeutische Wirksamkeit möglicherweise eingeschränkt
* < 70%: Unzureichende Adhärenz — wahrscheinlich subtherapeutische Wirkung
- Muster-Analyse: Morgen- vs. Abendeinnahme getrennt bewerten (Abenddosen werden häufiger vergessen).
- Streak-Tracking: Längste ununterbrochene Einnahmeperiode identifizieren und hervorheben.
- Wochenend-Effekt: Einnahmetreue an Wochenenden vs. Wochentagen vergleichen.
- Wirksamkeits-Korrelation: Direkte Verbindung zwischen Einnahmetreue und Vitalwert-Veränderungen herstellen.
* Beispiel: "In Wochen mit > 90% Einnahmetreue war der mittlere systolische RR 5 mmHg niedriger."
- Verpasste Dosen: Konsequenzen differenzieren (Antihypertensiva-Rebound vs. Statine = weniger zeitkritisch).
- Motivierende Gesprächsführung: Positive Formulierungen bevorzugen, Fortschritte anerkennen.
- Vergleiche Perioden hoher Adhärenz (>90%) mit den zugehörigen Vitalwerten. Zeige konkret: "In Wochen mit >90% Einnahmetreue war der systolische RR X mmHg niedriger."
- Korrelationen nur erwähnen wenn der r-Wert im Snapshot vorhanden und |r| > 0.4 ist. Falls das Feld nicht im Snapshot vorhanden ist, keine Korrelation interpretieren oder erfinden.
- Nutze historicalComparison um den Einfluss der Adhärenz auf Blutdruck und Puls zu quantifizieren, sofern die Daten im Snapshot vorhanden sind.
- Falls Stimmungsdaten verfügbar und Korrelation |r| > 0.4: Prüfe ob niedrige Adhärenz mit schlechterer Stimmung korreliert.
- Chronotherapie-Hinweis: Falls ein Blutdruck-Medikament vorhanden ist UND Compliance > 90% ABER BP nicht im Zielbereich: "Einnahme-Zeitpunkt mit dem Arzt besprechen — abendliche Einnahme kann bei einigen Patienten die nächtliche Blutdruckkontrolle verbessern."
- Mood-Adhärenz-Risiko: Falls moodAdherenceRisk = true: "Deine Stimmung war in den letzten Tagen niedrig. Erfahrungsgemäß kann das die Einnahmetreue in den kommenden Tagen beeinflussen. Tipp: Lege die Medikamente abends schon bereit."`;
const MEDCO_SECTION_DE = `METRIK — MEDIKAMENTEN-EINNAHMETREUE:
- Der Snapshot trägt overall (medicationCount, averageCompliance7, averageCompliance30) und medications[] je Medikament mit name, dose, schedulesPerDay, compliance7, compliance30, streak, taken7/skipped7/missed7, dailySeries (graded) und latestDay.
- Lies die Einnahmetreue als Prozentsatz erfüllter geplanter Dosen über die letzten 7 bzw. 30 Tage. Vergleiche compliance7 mit compliance30, um die jüngste Richtung gegen die eigene Baseline der Person zu sehen.
- Seltene Kadenz: Bei niedriger schedulesPerDay oder seltener Einnahme (z.B. Wocheninjektion) ist compliance7 rauschanfällig — eine einzige verpasste Dosis verschiebt den Prozentwert stark. Stütze die Einschätzung dann auf compliance30 (den tragenden Wert) und behandle compliance7 nur als groben Hinweis.
- Einordnung (zur Orientierung, nicht als Urteil): ≥ 90 % sehr verlässlich, 80-89 % gut, 70-79 % lückenhaft, < 70 % deutlich lückenhaft — die therapeutische Wirkung kann dann eingeschränkt sein.
- Positiv rahmen: streak ist die längste ununterbrochene Serie — bei guter Treue ausdrücklich anerkennen. Bei mehreren Medikamenten das mit der niedrigsten Treue benennen.
- Zusammenhänge zu Vitalwerten nur erwähnen, wenn ein r-Wert im Snapshot vorhanden und |r| > 0.4 ist — als Zusammenhang, nie als Ursache. Keine Wirkung erfinden, wenn die Daten fehlen.
- Eine Botschaft: Schließe mit EINEM machbaren Schritt, der zur Lücke passt (z.B. Abenddosen werden häufiger vergessen — eine feste Routine oder ein Reminder zur kritischen Uhrzeit kann helfen). Keine Schuldzuweisung.`;
const MEDCO_SECTION_EN = `DOMAIN — MEDICATION ADHERENCE:
- Adherence rating:
* ≥ 90%: Excellent adherence
* 80-89%: Good adherence
* 70-79%: Moderate adherence — therapeutic effect possibly reduced
* < 70%: Insufficient adherence — likely subtherapeutic effect
- Pattern analysis: Score morning vs. evening doses separately (evening doses are missed more often).
- Streak tracking: Identify and highlight the longest uninterrupted intake streak.
- Weekend effect: Compare weekend vs. weekday adherence.
- Effectiveness correlation: Draw a direct link between adherence and vital-sign movement.
* Example: "In weeks with > 90% adherence the mean systolic BP was 5 mmHg lower."
- Missed doses: Differentiate consequences (antihypertensive rebound vs. statins = less time-critical).
- Motivational interviewing: Prefer positive framing, acknowledge progress.
- Compare periods of high adherence (>90%) with the corresponding vitals. Be concrete: "In weeks with >90% adherence systolic BP was X mmHg lower."
- Mention correlations only if the r-value is present in the snapshot and |r| > 0.4. If the field is missing, do not interpret or invent a correlation.
- Use historicalComparison to quantify how adherence affects blood pressure and pulse, where data is available.
- If mood data is available and the correlation |r| > 0.4: Check whether low adherence aligns with worse mood.
- Chronotherapy note: If a BP medication exists AND compliance > 90% BUT BP is off target: "Discuss timing with your doctor — for some patients, evening dosing improves nocturnal BP control."
- Mood-adherence risk: If moodAdherenceRisk = true: "Your mood has been low recently. Experience shows this can affect adherence over the next few days. Tip: prepare your medication the evening before."`;
const MEDCO_SECTION_EN = `METRIC — MEDICATION ADHERENCE:
- The snapshot carries overall (medicationCount, averageCompliance7, averageCompliance30) and medications[] per medication with name, dose, schedulesPerDay, compliance7, compliance30, streak, taken7/skipped7/missed7, dailySeries (graded) and latestDay.
- Read adherence as the percentage of scheduled doses taken over the last 7 and 30 days. Compare compliance7 with compliance30 to see the recent direction against the person's own baseline.
- Rare cadence: with a low schedulesPerDay or an infrequent schedule (e.g. a weekly injection), compliance7 is noise-prone — a single missed dose swings the percentage sharply. Lean the assessment on compliance30 (the load-bearing value) and treat compliance7 only as a rough pointer.
- Placement (for orientation, not judgement): ≥ 90% very reliable, 80-89% good, 70-79% patchy, < 70% clearly patchy — therapeutic effect may then be reduced.
- Frame positively: streak is the longest uninterrupted run — acknowledge a good streak explicitly. With several medications, name the one with the lowest adherence.
- Mention links to vital signs only when an r-value is present in the snapshot and |r| > 0.4 — as an association, never a cause. Do not invent an effect when the data is absent.
- One message: close with ONE doable step that fits the gap (e.g. evening doses are missed more often — a fixed routine or a reminder at the critical time can help). No blame.`;
export function getMedicationComplianceSystemPrompt(locale: Locale): string {
const section = locale === "en" ? MEDCO_SECTION_EN : MEDCO_SECTION_DE;
@@ -60,14 +38,12 @@ export function getMedicationComplianceUserPrompt(
: "";
if (locale === "en") {
return `Date: ${todayKey} (Europe/Berlin)
Analyse medication adherence with focus on patterns, effectiveness correlation and concrete suggestions for improvement.
Use the correlation data and historical comparison to back up the link between adherence and vital signs.${ctxBlock}
Write one short assessment of this person's medication adherence: name the recent rate, place it against their own baseline (compliance7 vs compliance30), and close with one doable, blame-free step. Judge confidence from the event count and recency.${ctxBlock}
${snapshotJson}`;
}
return `Datum: ${todayKey} (Europe/Berlin)
Analysiere die Medikamenten-Einnahmetreue mit Fokus auf Muster, Wirksamkeitskorrelation und konkrete Verbesserungsvorschläge.
Nutze die Korrelationsdaten und den historischen Vergleich um den Zusammenhang zwischen Adhärenz und Vitalwerten zu belegen.${ctxBlock}
Schreibe eine kurze Einschätzung zur Einnahmetreue dieser Person: benenne die jüngste Rate, ordne sie gegen die eigene Baseline ein (compliance7 vs. compliance30) und schließe mit einem machbaren, wertfreien Schritt. Konfidenz aus Ereignisanzahl und Aktualität ableiten.${ctxBlock}
${snapshotJson}`;
}
+18 -18
View File
@@ -1,23 +1,23 @@
import type { Locale } from "@/lib/i18n/config";
import { getBaseSystemPrompt } from "./base-system";
const MOOD_SECTION_DE = `FACHSPEZIFISCH -- STIMMUNG/WOHLBEFINDEN:
- Stimmungsskala: 1 (sehr schlecht) bis 5 (sehr gut), Tagesmittelwerte.
- Trend-Stabilitat: Schwankungen < 0.5 Punkte uber 7 Tage sind normal.
- Anhaltende Phasen: Mehr als 3 Tage unter 2.5 oder uber 4.5 sind auffallig.
- Zusammenhange: Stimmung korreliert haufig mit Schlaf, Aktivitat, Blutdruck und Medikamenten-Compliance.
- Tags: Falls Stimmungs-Tags vorhanden sind, prufe ob bestimmte Tags mit Stimmungslevels korrelieren.
- Cross-Metrik: Korrelationen zu Gewicht, Blutdruck oder Puls nur erwähnen wenn der jeweilige r-Wert im Snapshot vorhanden und |r| > 0.4 ist. Falls das Feld nicht im Snapshot vorhanden ist, keine Korrelation interpretieren oder erfinden.
- Erzwinge keine Querverweise wenn kein klares Muster erkennbar ist.`;
const MOOD_SECTION_DE = `METRIK — STIMMUNG / WOHLBEFINDEN:
- Skala 1 (sehr schlecht) bis 5 (sehr gut). Der Snapshot trägt mood.summary + mood.series (graded, Tagesmittel), mood.target (grünes/oranges Band), mood.latestDayFocus und ggf. mood.tags (häufigste Stimmungs-Tags).
- Wochenmuster zählt: Vergleiche das recent-Mittel mit dem weekly/monthly-Mittel der Person. Schwankungen < 0.5 Punkte über die letzten Tage sind normal und kein Befund.
- Anhaltende Phasen ernst, aber ruhig benennen: mehrere Tage unter ~2.5 oder über ~4.5 sind erwähnenswert — als Beobachtung, nie als Diagnose und nie alarmierend.
- Bei anhaltend sehr niedriger Stimmung (mehrere Tage deutlich unter ~2.5): biete ruhig und autonomie-wahrend einen Unterstützungs-Pfad an — etwa "bei anhaltender Belastung kann ein Gespräch mit einer Vertrauensperson oder einer Fachperson helfen". Nie alarmierend, nie als Diagnose, kein Notfall-Ton; eine sanfte Option, kein Imperativ.
- Tags: Falls mood.tags vorhanden, kannst du ein wiederkehrendes Tag als möglichen Kontext nennen, ohne einen Zusammenhang zu behaupten.
- Cross-Metrik: crossMetricContext trägt Korrelationen zu Gewicht, Blutdruck und Puls; nur erwähnen, wenn vorhanden und |r| > 0.4 — als Zusammenhang, nie als Ursache. Erzwinge keinen Querverweis ohne klares Muster.
- Eine Botschaft: Schließe mit EINEM machbaren, freundlichen Schritt (z.B. an guten Tagen kurz festhalten, was geholfen hat). Bei stabil guter Stimmung das ehrlich anerkennen.`;
const MOOD_SECTION_EN = `DOMAIN — MOOD / WELL-BEING:
- Mood scale: 1 (very bad) to 5 (very good), daily means.
- Trend stability: Swings < 0.5 points over 7 days are normal.
- Persistent phases: More than 3 days below 2.5 or above 4.5 are notable.
- Associations: Mood often correlates with sleep, activity, blood pressure and medication compliance.
- Tags: If mood tags are present, check whether specific tags align with mood levels.
- Cross-metric: Mention correlations to weight, blood pressure or pulse only when the relevant r-value is present in the snapshot and |r| > 0.4. If the field is missing, do not interpret or invent a correlation.
- Do not force cross-links when no clear pattern is visible.`;
const MOOD_SECTION_EN = `METRIC — MOOD / WELL-BEING:
- Scale 1 (very bad) to 5 (very good). The snapshot carries mood.summary + mood.series (graded daily means), mood.target (green/orange band), mood.latestDayFocus and, where present, mood.tags (most frequent mood tags).
- The weekly pattern matters: compare the recent mean with the person's weekly/monthly mean. Swings < 0.5 points across the recent days are normal and not a finding.
- Name persistent phases seriously but calmly: several days below ~2.5 or above ~4.5 are worth noting — as an observation, never a diagnosis and never alarming.
- When mood stays very low for several days (clearly below ~2.5), offer a support pathway calmly and in an autonomy-preserving way — e.g. "if the strain persists, talking it through with someone you trust or a professional can help". Never alarming, never a diagnosis, no emergency tone; a gentle option, not an imperative.
- Tags: if mood.tags is present, you may name a recurring tag as possible context, without claiming a link.
- Cross-metric: crossMetricContext carries correlations to weight, blood pressure and pulse; mention only when present and |r| > 0.4 — as an association, never a cause. Do not force a cross-link without a clear pattern.
- One message: close with ONE doable, kind step (e.g. on good days, briefly note what helped). When mood is steadily good, acknowledge that honestly.`;
export function getMoodSystemPrompt(locale: Locale): string {
const section = locale === "en" ? MOOD_SECTION_EN : MOOD_SECTION_DE;
@@ -38,12 +38,12 @@ export function getMoodUserPrompt(
: "";
if (locale === "en") {
return `Date: ${todayKey} (Europe/Berlin)
Analyse the mood data with focus on trend, stability and links to other health metrics.${ctxBlock}
Write one short assessment of this person's mood: name the recent level, place it against their own weekly/monthly baseline, and close with one kind, doable step. Judge confidence from the entry count and recency.${ctxBlock}
${snapshotJson}`;
}
return `Datum: ${todayKey} (Europe/Berlin)
Analysiere die Stimmungsdaten mit Fokus auf Trend, Stabilitat und Zusammenhange mit anderen Gesundheitsmetriken.${ctxBlock}
Schreibe eine kurze Einschätzung zur Stimmung dieser Person: benenne das jüngste Niveau, ordne es gegen die eigene Wochen-/Monats-Baseline ein und schließe mit einem freundlichen, machbaren Schritt. Konfidenz aus Eintragsanzahl und Aktualität ableiten.${ctxBlock}
${snapshotJson}`;
}
+16 -50
View File
@@ -1,53 +1,21 @@
import type { Locale } from "@/lib/i18n/config";
import { getBaseSystemPrompt } from "./base-system";
const PULSE_SECTION_DE = `FACHSPEZIFISCH — PULS/HERZFREQUENZ:
- Ruhepuls-Zonen:
* Bradykardie: < 50 bpm (bei Sportlern normal, sonst abklärungsbedürftig)
* Athletisch: 50-60 bpm
* Normal: 60-100 bpm
* Tachykardie: > 100 bpm (abklärungsbedürftig)
- Trend-Stabilität: Ruhepuls-Variabilität < 5 bpm über 7 Tage ist normal.
- Stress-Indikatoren: Steigende Ruheherzfrequenz kann auf Stress, Schlafmangel oder Übertraining hinweisen.
- Erholung: Sinkender Ruhepuls über Wochen deutet auf verbesserte kardiovaskuläre Fitness hin.
- Medikamenten-Einfluss: Betablocker senken die Herzfrequenz — Compliance-Korrelation prüfen.
- Blutdruck-Korrelation: Puls × systolischen Druck = Doppelprodukt als Herzbelastungsindikator.
- Stimmungs-Korrelation: Chronischer Stress kann Ruhepuls erhöhen. Nur erwähnen wenn moodVsPulse-Korrelation im Snapshot vorhanden und |r| > 0.4.
- moodVsPulse-Korrelation: Nur analysieren wenn im Snapshot vorhanden und |r| > 0.4. Falls nicht vorhanden, keine Korrelation interpretieren.
- pulseVsSystolic-Korrelation: Nur analysieren wenn im Snapshot vorhanden und |r| > 0.4. Bewertung der hämodynamischen Kopplung.
- Vergleiche avg7 vs avg30 vs avg90 vs allTimeAvg um kurzfristige Abweichungen von der Langzeit-Baseline zu erkennen.
- Nutze historicalComparison.pulse: Bei ≥5 bpm Veränderung gegenüber der Baseline klinisch bewerten.
- Fitness-Interpretation:
* slope30 < -0.2 bpm/Tag (≈ -6 bpm/Monat): Positives Fitness-Signal, kardiovaskuläre Verbesserung
* slope30 > +0.2 bpm/Tag (≈ +6 bpm/Monat) OHNE Stimmungsabfall: Mögliche Dekonditionierung
* slope30 > +0.2 bpm/Tag MIT moodVsPulse r > 0.4: Stressbedingte Pulserhöhung
- Risikoband 80-100 bpm: Technisch "normal", aber erhöhtes CV-Risiko (+6-9% Mortalität pro 10 bpm, Meta-Analyse).
- Resting HR > 90 bpm: Unabhängiger Mortalitätsprädiktor — als eigenes Finding mit assessment "warning" erfassen.
- Inline-Chart: Wenn die summary oder ein finding den Ruhepulsverlauf in den Mittelpunkt stellt, kannst du genau einen Token metric:PULSE im Text einbetten, um das Pulsdiagramm darunter einzublenden.`;
const PULSE_SECTION_DE = `METRIK — PULS / HERZFREQUENZ:
- Der Snapshot trägt pulse.summary + pulse.series (graded) und pulse.target (greenMin/greenMax/orangeMin/orangeMax, inTargetPctLast30DailyPoints). pulse.latestDayFocus zeigt den jüngsten Tageswert und den Schritt zum Vortag.
- Tendenz über Wochen: Vergleiche das recent-Mittel mit dem weekly/monthly-Mittel der Person. Eine Abweichung ab ~5 bpm gegenüber der eigenen Baseline ist signifikant; eine Variabilität < 5 bpm über die letzten Tage ist normal und kein Befund.
- Richtung: Ein über Wochen sinkender Ruhepuls deutet auf bessere kardiovaskuläre Fitness; ein steigender kann auf Stress, Schlafmangel oder Übertraining hinweisen — als mögliche Erklärung, nie als Diagnose.
- Einordnungs-Anker (Ruhepuls): athletisch 50-60, normal 60-100. Werte im Band 80-100 sind technisch normal, langfristig aber günstiger niedriger. < 50 oder > 100 über mehrere Tage ist erwähnenswert, ohne zu alarmieren.
- Medikamenten-Bezug: Betablocker senken die Herzfrequenz. moodContext.moodVsPulseCorrelation und ein pulseVsSystolic-Zusammenhang nur erwähnen, wenn vorhanden und |r| > 0.4 — als Zusammenhang, nie als Ursache.
- Eine Botschaft: Liegt der Ruhepuls über der eigenen Baseline, schließe mit EINEM machbaren Schritt (z.B. ein paar Tage morgens vor dem Aufstehen messen, um eine saubere Ruhe-Baseline zu bekommen). Verbessert er sich, erkenne das ehrlich an.`;
const PULSE_SECTION_EN = `DOMAIN — PULSE / HEART RATE:
- Resting-pulse zones:
* Bradycardia: < 50 bpm (normal in athletes, otherwise needs evaluation)
* Athletic: 50-60 bpm
* Normal: 60-100 bpm
* Tachycardia: > 100 bpm (needs evaluation)
- Trend stability: Resting-pulse variability < 5 bpm over 7 days is normal.
- Stress indicators: A rising resting heart rate can point to stress, sleep loss or overtraining.
- Recovery: A falling resting pulse over weeks suggests improved cardiovascular fitness.
- Medication influence: Beta-blockers lower heart rate — check the compliance correlation.
- BP correlation: Pulse × systolic = rate-pressure product as a cardiac-load indicator.
- Mood correlation: Chronic stress can elevate resting pulse. Mention only if moodVsPulse correlation is present and |r| > 0.4.
- moodVsPulse correlation: Analyse only if present and |r| > 0.4. Otherwise do not interpret.
- pulseVsSystolic correlation: Analyse only if present and |r| > 0.4. Score haemodynamic coupling.
- Compare avg7 vs avg30 vs avg90 vs allTimeAvg to detect short-term deviations from the long-term baseline.
- Use historicalComparison.pulse: Score deltas ≥ 5 bpm against the baseline clinically.
- Fitness interpretation:
* slope30 < -0.2 bpm/day (≈ -6 bpm/month): positive fitness signal, cardiovascular improvement
* slope30 > +0.2 bpm/day (≈ +6 bpm/month) WITHOUT mood drop: possible deconditioning
* slope30 > +0.2 bpm/day WITH moodVsPulse r > 0.4: stress-driven pulse rise
- Risk band 80-100 bpm: Technically "normal", but elevated CV risk (+6-9% mortality per 10 bpm, meta-analysis).
- Resting HR > 90 bpm: Independent mortality predictor — capture as its own finding with assessment "warning".
- Inline chart: When a summary or finding centres on the resting-pulse trajectory, embed exactly one metric:PULSE token in the text to inline the pulse chart beneath it.`;
const PULSE_SECTION_EN = `METRIC — PULSE / HEART RATE:
- The snapshot carries pulse.summary + pulse.series (graded) and pulse.target (greenMin/greenMax/orangeMin/orangeMax, inTargetPctLast30DailyPoints). pulse.latestDayFocus shows the latest daily value and the step from the prior day.
- Trend over weeks: compare the recent mean with the person's weekly/monthly mean. A shift of ~5 bpm against their own baseline is significant; variability < 5 bpm across the recent days is normal and not a finding.
- Direction: a resting pulse falling over weeks suggests improving cardiovascular fitness; a rising one can point to stress, sleep loss or overtraining — as a possible explanation, never a diagnosis.
- Placement anchors (resting pulse): athletic 50-60, normal 60-100. Values in the 80-100 band are technically normal but tend to be more favourable lower over the long run. < 50 or > 100 across several days is worth noting without alarm.
- Medication link: beta-blockers lower heart rate. Mention moodContext.moodVsPulseCorrelation and any pulse-vs-systolic association only when present and |r| > 0.4 — as an association, never a cause.
- One message: if the resting pulse sits above the person's baseline, close with ONE doable step (e.g. take readings on waking for a few days to get a clean resting baseline). If it is improving, acknowledge that honestly.`;
export function getPulseSystemPrompt(locale: Locale): string {
const section = locale === "en" ? PULSE_SECTION_EN : PULSE_SECTION_DE;
@@ -68,14 +36,12 @@ export function getPulseUserPrompt(
: "";
if (locale === "en") {
return `Date: ${todayKey} (Europe/Berlin)
Analyse the pulse / heart-rate data with focus on resting-pulse trend, variability and links to medication and mood.
Use the precomputed correlations and historical comparison for a sound analysis.${ctxBlock}
Write one short assessment of this person's resting pulse: name the current level, place the recent days against their own weekly/monthly baseline, and close with one doable step. Judge confidence from the measurement count and recency.${ctxBlock}
${snapshotJson}`;
}
return `Datum: ${todayKey} (Europe/Berlin)
Analysiere die Puls-/Herzfrequenzdaten mit Fokus auf Ruhepulstrend, Variabilität und Zusammenhänge mit Medikation und Stimmung.
Nutze die vorberechneten Korrelationen und den historischen Vergleich für eine fundierte Analyse.${ctxBlock}
Schreibe eine kurze Einschätzung zum Ruhepuls dieser Person: benenne das aktuelle Niveau, ordne die jüngsten Tage gegen die eigene Wochen-/Monats-Baseline ein und schließe mit einem machbaren Schritt. Konfidenz aus Messanzahl und Aktualität ableiten.${ctxBlock}
${snapshotJson}`;
}
+22 -44
View File
@@ -1,47 +1,27 @@
import type { Locale } from "@/lib/i18n/config";
import { getBaseSystemPrompt } from "./base-system";
const WEIGHT_SECTION_DE = `FACHSPEZIFISCH — GEWICHT:
- BMI-Kontext immer mitbewerten (Größe aus Profil, falls verfügbar).
- WHO BMI-Klassifikation: Untergewicht < 18.5, Normalgewicht 18.5-24.9, Übergewicht 25.0-29.9, Adipositas I 30.0-34.9, II 35.0-39.9, III ≥ 40.0.
- Trend-Analyse: 7-Tage, 30-Tage und 90-Tage gleitende Durchschnitte vergleichen.
- Plateau-Erkennung: Gewichtsveränderung < ±0.5 kg über > 14 Tage als Plateau identifizieren.
- Realistische Zielprojektion: Maximal 0.5-1.0 kg/Woche als nachhaltiger Gewichtsverlust (DGE-Empfehlung).
- Gewichts-Blutdruck-Korrelation: Nur erwähnen wenn weightVsSystolic im Snapshot vorhanden und |r| > 0.4. Pro kg Gewichtsreduktion ca. 1 mmHg systolische Senkung.
- Medikamenten-Einfluss: Gewichtsrelevante Medikamente identifizieren (z.B. Betablocker, Cortison).
- Tageszeit-Schwankungen: Morgen- vs. Abendmessungen differenzieren (1-2 kg normal).
- Vergleiche das aktuelle 7-Tage-Mittel (historicalComparison.weight.current7dAvg) mit dem 30-Tage-Baseline (previous30dAvg). Bei >2 kg Differenz: klinisch bewerten und Ursachen diskutieren.
- Plateau-Definition: <0.5 kg Veränderung über >14 Tage (prüfe ob avg7 ≈ avg30 ≈ avg90).
- Nutze allTimeMin/allTimeMax/allTimeAvg um den aktuellen Wert historisch einzuordnen.
- Gewichtsmeilensteine (klinisch signifikant):
* 5% Verlust vom Startgewicht (allTimeMax): Metabolischer Benefit, KV-Risikoreduktion
* 10% Verlust: Erheblicher klinischer Nutzen
* Wenn erreicht: Explizit anerkennen und klinischen Kontext geben
- Zu schneller Verlust: > 1 kg/Woche über > 2 Wochen = Risiko für Muskelmasseverlust, nicht nachhaltig
- Body-Composition-Divergenz: Falls bodyCompositionDivergence.flag = true: "Gewicht stabil, aber Körperfettanteil steigend — möglicher Muskelmasseverlust. Krafttraining empfehlen."
- Schlaf-Gewicht-Verbindung: Falls sleep.avg7 < 6h UND weight.slope30 > 0: "Schlafmangel stört metabolische Regulation und kann Gewichtszunahme begünstigen."
- Inline-Chart: Bei einem klar gewichtsfokussierten summary- oder finding-Text kannst du genau einen Token (metric:WEIGHT, optional auch metric:BODY_FAT für Körperzusammensetzung) im Text einbetten, um das Diagramm darunter einzublenden.`;
const WEIGHT_SECTION_DE = `METRIK — GEWICHT:
- Der Snapshot trägt weight.summary + weight.series (graded). weight.latestDayFocus zeigt den jüngsten Tageswert, den Schritt zum vorherigen Messtag und ggf. den Blutdruck desselben Tages.
- Fokus dieser Karte ist der kontinuierliche TREND und das TEMPO über die Zeit (kg/Woche, Plateaus, Meilensteine), nicht die WHO-Band-Einordnung — die trägt die BMI-Karte. Den BMI-Wert nur als Nebenbezug nennen, nie das Band als zentrale Aussage hier.
- Wochen-/Monatstrend zählt, nicht der Einzelwert: Vergleiche das recent-Mittel mit dem weekly/monthly-Mittel der Person. Tageschwankungen von 1-2 kg (Wasser, Tageszeit) sind normal und kein Befund.
- Plateau: Verändert sich das Mittel über > ~2 Wochen um < 0.5 kg, ist das ein Plateau — bei einem Abnehmenden ehrlich benennen, ohne zu entmutigen.
- Tempo: Nachhaltiger Verlust liegt bei ~0.5-1.0 kg/Woche (DGE). Anhaltend > 1 kg/Woche ist eher Wasser/Muskel als Fett — vorsichtig einordnen.
- Meilensteine gegen das eigene Maximum: ~5 % Verlust vom höchsten Wert (series-Max) bringt bereits metabolischen Nutzen, ~10 % einen deutlichen — wenn erreicht, ausdrücklich anerkennen.
- BMI-Bezug nur als knapper Nebensatz, wenn die Größe im Profil vorliegt (sonst nicht behaupten); die WHO-Band-Einordnung selbst gehört auf die BMI-Karte, nicht hierher.
- Zusammenhänge: weightVsSystolic.correlation / weightVsMeanBloodPressure.correlation und moodContext.moodVsWeightCorrelation nur erwähnen, wenn vorhanden und |r| > 0.4 — als Zusammenhang, nie als Ursache.
- Eine Botschaft: Schließe mit EINEM machbaren Schritt, der zur Richtung passt (z.B. bei einem Plateau zur selben Tageszeit wiegen, um den echten Trend zu sehen, statt am Tagesrauschen zu hängen).`;
const WEIGHT_SECTION_EN = `DOMAIN — WEIGHT:
- Always include BMI context (height from profile, if available).
- WHO BMI classification: Underweight < 18.5, Normal 18.5-24.9, Overweight 25.0-29.9, Obesity I 30.0-34.9, II 35.0-39.9, III ≥ 40.0.
- Trend analysis: Compare 7-day, 30-day and 90-day moving averages.
- Plateau detection: Flag a weight change < ±0.5 kg over > 14 days as a plateau.
- Realistic target projection: At most 0.5-1.0 kg/week is sustainable weight loss (DGE recommendation).
- Weight-BP correlation: Mention only if weightVsSystolic is present in the snapshot and |r| > 0.4. Roughly 1 mmHg systolic drop per kg lost.
- Medication influence: Identify weight-relevant medications (e.g. beta-blockers, cortisone).
- Time-of-day swings: Differentiate morning vs. evening readings (1-2 kg normal).
- Compare the current 7-day mean (historicalComparison.weight.current7dAvg) with the 30-day baseline (previous30dAvg). For deltas > 2 kg: assess clinically and discuss likely causes.
- Plateau definition: < 0.5 kg change over > 14 days (check whether avg7 ≈ avg30 ≈ avg90).
- Use allTimeMin/allTimeMax/allTimeAvg to anchor the current value historically.
- Weight milestones (clinically significant):
* 5% loss from starting weight (allTimeMax): metabolic benefit, CV risk reduction
* 10% loss: substantial clinical benefit
* If reached: acknowledge explicitly and give clinical context
- Too rapid loss: > 1 kg/week sustained > 2 weeks = risk of muscle-mass loss, not sustainable
- Body-composition divergence: If bodyCompositionDivergence.flag = true: "Weight stable but body-fat percentage rising — possible muscle-mass loss. Recommend strength training."
- Sleep-weight link: If sleep.avg7 < 6h AND weight.slope30 > 0: "Sleep loss disrupts metabolic regulation and can favour weight gain."
- Inline chart: When a summary or finding text is centred on weight, embed exactly one token (metric:WEIGHT, or metric:BODY_FAT for body-composition findings) inside that text to inline the chart underneath.`;
const WEIGHT_SECTION_EN = `METRIC — WEIGHT:
- The snapshot carries weight.summary + weight.series (graded). weight.latestDayFocus shows the latest daily value, the step from the previous measured day and, where present, the same-day blood pressure.
- This card's focus is the continuous TREND and PACE over time (kg/week, plateaus, milestones), not WHO-band placement — the BMI card carries that. Mention the BMI value only as a side reference, never make the band the central message here.
- The weekly/monthly trend matters, not the single value: compare the recent mean with the person's weekly/monthly mean. Day-to-day swings of 1-2 kg (water, time of day) are normal and not a finding.
- Plateau: if the mean moves < 0.5 kg over > ~2 weeks, that is a plateau — name it honestly for someone who is losing, without discouraging.
- Pace: sustainable loss is ~0.5-1.0 kg/week (DGE). Sustained > 1 kg/week is more likely water/muscle than fat — frame cautiously.
- Milestones against their own maximum: ~5% loss from the highest value (series max) already brings metabolic benefit, ~10% a substantial one — acknowledge explicitly when reached.
- BMI reference only as a brief aside when height is in the profile (do not claim it otherwise); the WHO-band placement itself belongs on the BMI card, not here.
- Associations: mention weightVsSystolic.correlation / weightVsMeanBloodPressure.correlation and moodContext.moodVsWeightCorrelation only when present and |r| > 0.4 — as an association, never a cause.
- One message: close with ONE doable step that fits the direction (e.g. on a plateau, weigh at the same time of day to see the real trend rather than the daily noise).`;
export function getWeightSystemPrompt(locale: Locale): string {
const section = locale === "en" ? WEIGHT_SECTION_EN : WEIGHT_SECTION_DE;
@@ -62,14 +42,12 @@ export function getWeightUserPrompt(
: "";
if (locale === "en") {
return `Date: ${todayKey} (Europe/Berlin)
Analyse the weight trajectory with focus on trends, BMI classification and links to other vital signs.
Use the temporal layers (avg7, avg30, avg90, allTime) and the historical comparison for a nuanced assessment.${ctxBlock}
Write one short assessment of this person's weight: name the current level and direction, place the recent days against their own weekly/monthly baseline as a continuous trend and pace (kg/week, plateau, milestone — not the single value, and not the WHO band, which the BMI card covers), and close with one doable step. Judge confidence from the measurement count and recency.${ctxBlock}
${snapshotJson}`;
}
return `Datum: ${todayKey} (Europe/Berlin)
Analysiere die Gewichtsentwicklung mit Fokus auf Trends, BMI-Klassifikation und Zusammenhang mit anderen Vitalwerten.
Nutze die temporalen Schichten (avg7, avg30, avg90, allTime) und den historischen Vergleich für eine differenzierte Bewertung.${ctxBlock}
Schreibe eine kurze Einschätzung zum Gewicht dieser Person: benenne Niveau und Richtung, ordne die jüngsten Tage gegen die eigene Wochen-/Monats-Baseline als kontinuierlichen Trend und Tempo ein (kg/Woche, Plateau, Meilenstein — nicht der Einzelwert und nicht das WHO-Band, das die BMI-Karte trägt) und schließe mit einem machbaren Schritt. Konfidenz aus Messanzahl und Aktualität ableiten.${ctxBlock}
${snapshotJson}`;
}
+1 -1
View File
@@ -176,7 +176,7 @@ export const CHART_OVERLAY_KEYS = [
"medications",
"sleep",
"steps",
// v1.4.25 W16a — VO2 max chart-row on /insights/puls. Independent
// v1.4.25 W16a — VO2 max chart-row on /insights/pulse. Independent
// overlay-prefs slot so the cardio sub-page's VO2 chart-cog state
// doesn't bleed into the pulse chart sitting directly above it.
"vo2Max",
@@ -261,7 +261,7 @@ describe("Coach disable cascade invariant", () => {
// directly. Cross-cut gates on sub-pages (`/targets` page drawer,
// `<TargetCard>` per-card CTA) sit OUTSIDE the fixture and are owned
// by sibling invariants. A future contributor who lands a new Coach
// mount on (e.g.) `/insights/blutdruck/page.tsx` would not trip the
// mount on (e.g.) `/insights/blood-pressure/page.tsx` would not trip the
// surface-count check at all — the new site is on a sub-page and
// would silently leak.
//
+114 -28
View File
@@ -21,33 +21,85 @@
* `DEFAULT_INSIGHTS_LAYOUT.tiles[].order` drives the visual order.
* Exported so the API Zod `tileIdEnum` can be built from a single
* source of truth.
*
* v1.8.0 — the canonical ids are English, matching the routed slugs
* (`/insights/<slug>`) and the naming-convention ADR
* (`docs/adr/0001-insights-naming-convention.md`). The ids were German
* through v1.7.x and the iOS layout-sync contract
* (`GET/PUT /api/insights/layout`) speaks them, so the layout endpoint
* keeps ACCEPTING the legacy German ids and NORMALISES them to the
* canonical English ones on both read and write — see
* `LEGACY_INSIGHTS_TILE_ID_ALIASES` + `normalizeInsightsTileId` below.
* No client breaks; iOS migrates to the English ids at its own pace.
*/
export const INSIGHTS_TILE_IDS = [
"overview",
// ── vitals ──
"blutdruck",
"puls",
"sauerstoff",
"koerpertemperatur",
"blood-pressure",
"pulse",
"oxygen",
"body-temperature",
// ── body composition ──
"gewicht",
"weight",
"bmi",
// ── activity ──
"aktive-energie",
"active-energy",
"workouts",
// ── sleep ──
"schlaf",
"sleep",
// ── cardiovascular ──
"ruhepuls",
"resting-pulse",
"hrv",
// ── mood ──
"stimmung",
"mood",
// ── events ──
"medikamente",
"medications",
] as const;
export type InsightsTileId = (typeof INSIGHTS_TILE_IDS)[number];
/**
* Legacy (≤ v1.7.x) German tile ids mapped to their canonical English
* replacement. The layout endpoint accepts these as input so the iOS
* client's stored layouts keep round-tripping after the v1.8.0 rename;
* every write path normalises them away before persisting (see
* `normalizeInsightsTileId`). `bmi`, `hrv`, `workouts`, and `overview`
* were already language-neutral and need no alias.
*/
export const LEGACY_INSIGHTS_TILE_ID_ALIASES: Record<string, InsightsTileId> = {
blutdruck: "blood-pressure",
puls: "pulse",
sauerstoff: "oxygen",
koerpertemperatur: "body-temperature",
gewicht: "weight",
"aktive-energie": "active-energy",
schlaf: "sleep",
ruhepuls: "resting-pulse",
stimmung: "mood",
medikamente: "medications",
};
/**
* Every id the layout endpoint accepts on input — canonical English
* plus the legacy German aliases. The Zod `tileIdEnum` is built from
* this union so a PUT carrying old ids passes validation rather than
* tripping a 422; `normalizeInsightsTileId` collapses the union back to
* the canonical id before anything persists.
*/
export const ACCEPTED_INSIGHTS_TILE_IDS = [
...INSIGHTS_TILE_IDS,
...Object.keys(LEGACY_INSIGHTS_TILE_ID_ALIASES),
] as const;
/**
* Collapse a legacy German tile id onto its canonical English id.
* Canonical ids (and unknown ids) pass through unchanged — the
* resolver's known-id filter drops anything truly unrecognised.
*/
export function normalizeInsightsTileId(id: string): string {
return LEGACY_INSIGHTS_TILE_ID_ALIASES[id] ?? id;
}
export interface InsightsTileConfig {
id: InsightsTileId;
visible: boolean;
@@ -75,19 +127,19 @@ export const DEFAULT_INSIGHTS_LAYOUT: InsightsLayout = {
version: INSIGHTS_LAYOUT_VERSION,
tiles: [
{ id: "overview", visible: true, order: 0 },
{ id: "blutdruck", visible: true, order: 1 },
{ id: "puls", visible: true, order: 2 },
{ id: "sauerstoff", visible: false, order: 3 },
{ id: "koerpertemperatur", visible: false, order: 4 },
{ id: "gewicht", visible: true, order: 5 },
{ id: "blood-pressure", visible: true, order: 1 },
{ id: "pulse", visible: true, order: 2 },
{ id: "oxygen", visible: false, order: 3 },
{ id: "body-temperature", visible: false, order: 4 },
{ id: "weight", visible: true, order: 5 },
{ id: "bmi", visible: true, order: 6 },
{ id: "aktive-energie", visible: false, order: 7 },
{ id: "active-energy", visible: false, order: 7 },
{ id: "workouts", visible: true, order: 8 },
{ id: "schlaf", visible: false, order: 9 },
{ id: "ruhepuls", visible: false, order: 10 },
{ id: "sleep", visible: false, order: 9 },
{ id: "resting-pulse", visible: false, order: 10 },
{ id: "hrv", visible: false, order: 11 },
{ id: "stimmung", visible: true, order: 12 },
{ id: "medikamente", visible: true, order: 13 },
{ id: "mood", visible: true, order: 12 },
{ id: "medications", visible: true, order: 13 },
],
};
@@ -101,13 +153,22 @@ export function resolveInsightsLayout(raw: unknown): InsightsLayout {
return DEFAULT_INSIGHTS_LAYOUT;
}
// Drop tile ids the current build does not know about so a future
// retirement (mirroring the dashboard's v1.4.28 `glp1` drop) doesn't
// wedge the PUT round-trip on the next save. Filtering on read keeps
// the GET shape current-build-safe and lets the Settings UI re-PUT
// a clean array.
// v1.8.0 — normalise legacy German tile ids onto their canonical
// English replacement BEFORE the known-id filter so a layout persisted
// by a ≤ v1.7.x client (or the iOS app still speaking the old ids)
// survives the read instead of being silently dropped as "unknown".
// Dedupe afterwards: a layout carrying both the legacy and the
// canonical id (e.g. a partial migration) keeps the first occurrence
// so the round-trip stays idempotent.
const knownIds = new Set<string>(INSIGHTS_TILE_IDS);
const filtered = candidate.tiles.filter((t) => knownIds.has(t.id));
const seen = new Set<string>();
const filtered: InsightsTileConfig[] = candidate.tiles
.map((t) => ({ ...t, id: normalizeInsightsTileId(t.id) }))
.filter((t): t is InsightsTileConfig => {
if (!knownIds.has(t.id) || seen.has(t.id)) return false;
seen.add(t.id);
return true;
});
// Merge with defaults so new tiles introduced in later versions show
// up automatically (invisible by default, the user opts in).
@@ -128,16 +189,41 @@ export function resolveInsightsLayout(raw: unknown): InsightsLayout {
};
}
/**
* Input shape for {@link serializeInsightsLayout}. Looser than
* {@link InsightsLayout}: the tile `id` is a bare `string` so the PUT
* handler can hand a Zod-parsed body that still carries legacy German
* aliases — `serializeInsightsLayout` normalises them onto canonical
* {@link InsightsTileId} values on the way through.
*/
export interface InsightsLayoutInput {
version: number;
tiles: Array<{ id: string; visible: boolean; order: number }>;
}
export function serializeInsightsLayout(
layout: InsightsLayout,
layout: InsightsLayoutInput,
): InsightsLayout {
// v1.8.0 — normalise legacy German tile ids onto canonical English
// before persisting so the stored blob is always canonical even when
// the PUT body carried the old ids (the Zod enum accepts both via
// `ACCEPTED_INSIGHTS_TILE_IDS`). Dedupe so a body sending both the
// legacy and canonical id for one tile collapses to a single entry,
// keeping the dense 0-based order contiguous.
const seen = new Set<string>();
return {
version: INSIGHTS_LAYOUT_VERSION,
tiles: layout.tiles
.slice()
.sort((a, b) => a.order - b.order)
.map((t) => ({ ...t, id: normalizeInsightsTileId(t.id) }))
.filter((t) => {
if (seen.has(t.id)) return false;
seen.add(t.id);
return true;
})
.map((t, i) => ({
id: t.id,
id: t.id as InsightsTileId,
visible: t.visible,
order: i, // normalize to 0-based dense order
})),
@@ -5,14 +5,15 @@ vi.mock("@/lib/db", () => ({
user: { findUnique: vi.fn() },
auditLog: { findFirst: vi.fn(), create: vi.fn() },
measurement: { findMany: vi.fn() },
measurementRollup: { findMany: vi.fn() },
medication: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
},
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProvider: vi.fn(),
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
}));
vi.mock("@/lib/insights/memory", () => ({
@@ -25,22 +26,42 @@ vi.mock("@/lib/medication-category", () => ({
}));
import { prisma } from "@/lib/db";
import { resolveProvider } from "@/lib/ai/provider";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { getMedicationCategories } from "@/lib/medication-category";
import { generateBloodPressureStatusForUser } from "../blood-pressure-status";
const dayMs = 24 * 60 * 60 * 1000;
function stubCompletion(
content: string,
capture?: { userPrompt: string | null },
) {
vi.mocked(runStatusCompletion).mockImplementation(
async (args: { userPrompt: string }) => {
if (capture) capture.userPrompt = args.userPrompt;
return {
kind: "ok",
content,
providerType: "anthropic",
model: "x",
tokensUsed: 1,
} as never;
},
);
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getMedicationCategories).mockResolvedValue({});
// Cold rollup tier: the BP channels fold monthly/yearly from the
// full-history `measurement.findMany` fallback on a tier miss.
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never);
});
describe("generateBloodPressureStatusForUser — v1.4.6 bucketed payload", () => {
it("emits {daily, monthly} per metric series with correct field shape", async () => {
describe("generateBloodPressureStatusForUser — graded payload", () => {
it("emits a graded {recent, weekly, monthly} per-metric series, not the full daily array", async () => {
const now = new Date();
// 3 years of SYS data — covers daily window + monthly window.
const records: Array<{
type: string;
value: number;
@@ -74,17 +95,7 @@ describe("generateBloodPressureStatusForUser — v1.4.6 bucketed payload", () =>
} as never);
const captured: { userPrompt: string | null } = { userPrompt: null };
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async (args: { userPrompt: string }) => {
captured.userPrompt = args.userPrompt;
return {
content: '{"summary":"OK"}',
model: "x",
tokensUsed: 1,
};
}),
} as never);
stubCompletion('{"summary":"OK"}', captured);
await generateBloodPressureStatusForUser("user-1", { locale: "en" });
@@ -92,25 +103,27 @@ describe("generateBloodPressureStatusForUser — v1.4.6 bucketed payload", () =>
const snapshot = JSON.parse(match![0]);
const sys = snapshot.bloodPressure.systolic.series;
expect(sys).toHaveProperty("daily");
expect(sys).toHaveProperty("recent");
expect(sys).toHaveProperty("weekly");
expect(sys).toHaveProperty("monthly");
expect(sys.daily.length).toBeGreaterThan(0);
expect(sys.monthly.length).toBeGreaterThan(0);
expect(sys.daily[0]).toHaveProperty("dayOffset");
expect(sys.daily[0]).toHaveProperty("value");
expect(sys.daily[0]).toHaveProperty("n");
expect(sys.monthly[0]).toHaveProperty("monthOffset");
expect(sys.monthly[0]).toHaveProperty("value");
expect(sys.monthly[0]).toHaveProperty("n");
expect(sys).toHaveProperty("yearly");
expect(sys.recent.length).toBeLessThanOrEqual(21);
expect(sys.recent[0]).toHaveProperty("date");
expect(sys.recent[0]).toHaveProperty("mean");
expect(sys.monthly[0]).toHaveProperty("month");
const dia = snapshot.bloodPressure.diastolic.series;
expect(dia.daily.length).toBeGreaterThan(0);
expect(dia.monthly.length).toBeGreaterThan(0);
expect(dia).toHaveProperty("recent");
expect(dia).toHaveProperty("monthly");
expect(dia.recent.length).toBeLessThanOrEqual(21);
// Embedded correlation pair arrays are capped, not full-length.
expect(snapshot.weightVsSystolic.pairs.length).toBeLessThanOrEqual(30);
});
});
describe("generateBloodPressureStatusForUser — v1.4.41 timeout-stub persistence", () => {
it("persists a sentinel row keyed to today when the provider times out", async () => {
describe("generateBloodPressureStatusForUser — timeout/error never persists", () => {
it("serves the fallback without writing a cache row on timeout", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
} as never);
@@ -128,87 +141,65 @@ describe("generateBloodPressureStatusForUser — v1.4.41 timeout-stub persistenc
createdAt: new Date(),
} as never);
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(
() =>
new Promise(() => {
/* never resolves */
}),
),
} as never);
vi.useFakeTimers();
const promise = generateBloodPressureStatusForUser("user-1", {
locale: "en",
});
await vi.advanceTimersByTimeAsync(25_000);
const result = await promise;
vi.useRealTimers();
expect(result.text).toBeTruthy();
expect(result.cached).toBe(true);
expect(result.updatedAt).toBeTruthy();
const createCalls = vi.mocked(prisma.auditLog.create).mock.calls;
expect(createCalls.length).toBe(1);
const details = (createCalls[0][0] as { data: { details: string } }).data
.details;
const parsed = JSON.parse(details) as {
dateKey?: string;
text?: string;
timeout?: boolean;
model?: string;
};
expect(parsed.text).toBeTruthy();
expect(parsed.timeout).toBe(true);
expect(parsed.model).toBe("timeout-stub");
expect(parsed.dateKey).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
it("subsequent mounts short-circuit at the cache lookup and skip the race", async () => {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(new Date());
const y = parts.find((p) => p.type === "year")!.value;
const m = parts.find((p) => p.type === "month")!.value;
const d = parts.find((p) => p.type === "day")!.value;
const todayKey = `${y}-${m}-${d}`;
const stubRow = {
createdAt: new Date(),
details: JSON.stringify({
dateKey: todayKey,
locale: "en",
text: "Blood pressure assessment fallback…",
timeout: true,
}),
};
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(stubRow as never);
const providerCall = vi.fn();
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: providerCall,
vi.mocked(runStatusCompletion).mockResolvedValue({
kind: "timeout",
} as never);
const result = await generateBloodPressureStatusForUser("user-1", {
locale: "en",
});
expect(providerCall).not.toHaveBeenCalled();
expect(result.text).toBe("Blood pressure assessment fallback…");
expect(result.text).toBeTruthy();
expect(result.cached).toBe(true);
expect(result.updatedAt).toBe(stubRow.createdAt.toISOString());
expect(result.updatedAt).toBeNull();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
});
});
describe("generateBloodPressureStatusForUser — cache-read skips a stub", () => {
it("regenerates when the only cached row is a timeout stub", async () => {
const now = new Date();
const todayKey = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
}).format(now);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({
createdAt: now,
details: JSON.stringify({
dateKey: todayKey,
locale: "en",
text: "Blood pressure assessment fallback…",
model: "timeout-stub",
timeout: true,
}),
} as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ type: "BLOOD_PRESSURE_SYS", value: 132, measuredAt: now },
{ type: "BLOOD_PRESSURE_DIA", value: 84, measuredAt: now },
] as never);
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: now,
} as never);
stubCompletion('{"summary":"Fresh BP assessment."}');
const result = await generateBloodPressureStatusForUser("user-1", {
locale: "en",
});
expect(runStatusCompletion).toHaveBeenCalledTimes(1);
expect(result.text).toBe("Fresh BP assessment.");
expect(result.cached).toBe(false);
});
});
describe("generateBloodPressureStatusForUser — token-leak hardening (v1.4.27 F16)", () => {
it("strips metric: tokens out of the cached text before persisting", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
@@ -228,15 +219,9 @@ describe("generateBloodPressureStatusForUser — token-leak hardening (v1.4.27 F
createdAt: new Date(),
} as never);
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async () => ({
content:
'{"summary":"Systolic averaged 132. metric:BLOOD_PRESSURE_SYS Diastolic stable. metric:BLOOD_PRESSURE_DIA"}',
model: "x",
tokensUsed: 1,
})),
} as never);
stubCompletion(
'{"summary":"Systolic averaged 132. metric:BLOOD_PRESSURE_SYS Diastolic stable. metric:BLOOD_PRESSURE_DIA"}',
);
const result = await generateBloodPressureStatusForUser("user-1", {
locale: "en",
+111 -134
View File
@@ -5,11 +5,12 @@ vi.mock("@/lib/db", () => ({
user: { findUnique: vi.fn() },
auditLog: { findFirst: vi.fn(), create: vi.fn() },
measurement: { findMany: vi.fn() },
measurementRollup: { findMany: vi.fn() },
},
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProvider: vi.fn(),
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
}));
vi.mock("@/lib/insights/memory", () => ({
@@ -18,17 +19,39 @@ vi.mock("@/lib/insights/memory", () => ({
}));
import { prisma } from "@/lib/db";
import { resolveProvider } from "@/lib/ai/provider";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { generateBmiStatusForUser } from "../bmi-status";
const dayMs = 24 * 60 * 60 * 1000;
/** A `runStatusCompletion` stub returning fixed content, capturing the prompt. */
function stubCompletion(
content: string,
capture?: { userPrompt: string | null },
) {
vi.mocked(runStatusCompletion).mockImplementation(
async (args: { userPrompt: string }) => {
if (capture) capture.userPrompt = args.userPrompt;
return {
kind: "ok",
content,
providerType: "anthropic",
model: "x",
tokensUsed: 1,
} as never;
},
);
}
beforeEach(() => {
vi.resetAllMocks();
// Cold rollup tier: the BMI graded series scales the WEIGHT tier, which
// folds from the full-history `measurement.findMany` fallback on a miss.
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never);
});
describe("generateBmiStatusForUser — v1.4.6 bucketed payload", () => {
it("emits {daily, monthly} BMI series derived from bucketed weight", async () => {
describe("generateBmiStatusForUser — graded payload", () => {
it("emits a graded {recent, weekly, monthly} BMI series, not the full daily array", async () => {
const now = new Date();
const records: Array<{ value: number; measuredAt: Date }> = [];
for (let day = 0; day < 1000; day++) {
@@ -48,17 +71,7 @@ describe("generateBmiStatusForUser — v1.4.6 bucketed payload", () => {
} as never);
const captured: { userPrompt: string | null } = { userPrompt: null };
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async (args: { userPrompt: string }) => {
captured.userPrompt = args.userPrompt;
return {
content: '{"summary":"OK"}',
model: "x",
tokensUsed: 1,
};
}),
} as never);
stubCompletion('{"summary":"OK"}', captured);
await generateBmiStatusForUser("user-1", { locale: "en" });
@@ -66,21 +79,25 @@ describe("generateBmiStatusForUser — v1.4.6 bucketed payload", () => {
const snapshot = JSON.parse(match![0]);
const bmi = snapshot.bmi.series;
expect(bmi).toHaveProperty("daily");
expect(bmi).toHaveProperty("recent");
expect(bmi).toHaveProperty("weekly");
expect(bmi).toHaveProperty("monthly");
expect(bmi.daily.length).toBeGreaterThan(0);
expect(bmi.monthly.length).toBeGreaterThan(0);
expect(bmi.daily[0]).toHaveProperty("dayOffset");
expect(bmi.daily[0]).toHaveProperty("value");
expect(bmi.daily[0]).toHaveProperty("n");
expect(bmi.monthly[0]).toHaveProperty("monthOffset");
expect(bmi.monthly[0]).toHaveProperty("value");
expect(bmi.monthly[0]).toHaveProperty("n");
expect(bmi).toHaveProperty("yearly");
expect(bmi.recent.length).toBeLessThanOrEqual(21);
expect(bmi.recent[0]).toHaveProperty("date");
expect(bmi.recent[0]).toHaveProperty("mean");
expect(bmi.monthly[0]).toHaveProperty("month");
const total =
bmi.recent.length +
bmi.weekly.length +
bmi.monthly.length +
bmi.yearly.length;
expect(total).toBeLessThanOrEqual(50);
});
});
describe("generateBmiStatusForUser — v1.4.37 timeout-stub persistence", () => {
it("persists a sentinel row keyed to today when the provider times out", async () => {
describe("generateBmiStatusForUser — timeout/error never persists", () => {
it("serves the fallback without writing a cache row on timeout", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
heightCm: 175,
} as never);
@@ -92,111 +109,77 @@ describe("generateBmiStatusForUser — v1.4.37 timeout-stub persistence", () =>
createdAt: new Date(),
} as never);
// Simulate a provider that never resolves before the timeout. The
// helper races the call against `STATUS_PROVIDER_TIMEOUT_MS`; a
// promise that hangs forever cuts the race short.
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(
() => new Promise(() => {
/* never resolves */
}),
),
} as never);
// Shrink the timeout window so this test does not hang the suite
// for the full 20 s. The withTimeout helper polls in ~1 s
// increments; this still exercises the same race shape.
vi.useFakeTimers();
const promise = generateBmiStatusForUser("user-1", { locale: "en" });
await vi.advanceTimersByTimeAsync(25_000);
const result = await promise;
vi.useRealTimers();
// The user-facing payload still reads the deterministic no-key
// fallback, but the cached/updatedAt fields signal "this is a
// stub" so the next mount short-circuits at the cache lookup.
expect(result.text).toBeTruthy();
expect(result.cached).toBe(true);
expect(result.updatedAt).toBeTruthy();
const createCalls = vi.mocked(prisma.auditLog.create).mock.calls;
expect(createCalls.length).toBe(1);
const details = (createCalls[0][0] as { data: { details: string } }).data
.details;
const parsed = JSON.parse(details) as {
dateKey?: string;
text?: string;
timeout?: boolean;
model?: string;
};
expect(parsed.text).toBeTruthy();
expect(parsed.timeout).toBe(true);
expect(parsed.model).toBe("timeout-stub");
// The dateKey must read as a valid YYYY-MM-DD so the cache hit
// logic on the next mount matches against today's berlin key.
expect(parsed.dateKey).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
it("subsequent mounts short-circuit at the cache lookup and skip the race", async () => {
// First mount: cache miss, provider times out, stub persisted.
// Simulate that prior state by handing back the stub row from
// findFirst on the second invocation.
const stubRow = {
createdAt: new Date(),
details: JSON.stringify({
dateKey: new Intl.DateTimeFormat("en-US", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
})
.formatToParts(new Date())
.reduce<Record<string, string>>((acc, p) => {
if (p.type !== "literal") acc[p.type] = p.value;
return acc;
}, {}),
locale: "en",
text: "BMI is a directional metric…",
timeout: true,
}),
};
// Build a real YYYY-MM-DD string the same way the source helper
// does so the cache-key match is exact.
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(new Date());
const y = parts.find((p) => p.type === "year")!.value;
const m = parts.find((p) => p.type === "month")!.value;
const d = parts.find((p) => p.type === "day")!.value;
const todayKey = `${y}-${m}-${d}`;
stubRow.details = JSON.stringify({
dateKey: todayKey,
locale: "en",
text: "BMI is a directional metric…",
timeout: true,
});
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(stubRow as never);
const providerCall = vi.fn();
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: providerCall,
vi.mocked(runStatusCompletion).mockResolvedValue({
kind: "timeout",
} as never);
const result = await generateBmiStatusForUser("user-1", { locale: "en" });
// Cache short-circuit fires before the provider call — no race,
// no second persist.
expect(providerCall).not.toHaveBeenCalled();
expect(result.text).toBe("BMI is a directional metric…");
// The user-facing payload reads the deterministic fallback, but
// NOTHING is persisted — so the next mount re-attempts a real
// generation instead of sticking the fallback for the day.
expect(result.text).toBeTruthy();
expect(result.cached).toBe(true);
expect(result.updatedAt).toBe(stubRow.createdAt.toISOString());
expect(result.updatedAt).toBeNull();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
});
it("serves the fallback without writing a cache row on provider error", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
heightCm: 175,
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ value: 80, measuredAt: new Date() },
] as never);
vi.mocked(runStatusCompletion).mockResolvedValue({
kind: "error",
} as never);
const result = await generateBmiStatusForUser("user-1", { locale: "en" });
expect(result.text).toBeTruthy();
expect(result.updatedAt).toBeNull();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
});
});
describe("generateBmiStatusForUser — cache-read skips a stub", () => {
it("regenerates when the only cached row is a timeout stub", async () => {
const now = new Date();
const todayKey = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
}).format(now);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
heightCm: 175,
} as never);
// The most recent cached row is a stub keyed to today.
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({
createdAt: now,
details: JSON.stringify({
dateKey: todayKey,
locale: "en",
text: "Generic stub fallback.",
model: "timeout-stub",
timeout: true,
}),
} as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ value: 80, measuredAt: now },
] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: now,
} as never);
stubCompletion('{"summary":"Fresh real assessment."}');
const result = await generateBmiStatusForUser("user-1", { locale: "en" });
// The stub must NOT be served — a fresh generation runs instead.
expect(runStatusCompletion).toHaveBeenCalledTimes(1);
expect(result.text).toBe("Fresh real assessment.");
expect(result.cached).toBe(false);
});
});
describe("generateBmiStatusForUser — token-leak hardening (v1.4.27 F16)", () => {
@@ -212,15 +195,9 @@ describe("generateBmiStatusForUser — token-leak hardening (v1.4.27 F16)", () =
createdAt: new Date(),
} as never);
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async () => ({
content:
'{"summary":"BMI sits in the green band. metric:WEIGHT and steady."}',
model: "x",
tokensUsed: 1,
})),
} as never);
stubCompletion(
'{"summary":"BMI sits in the green band. metric:WEIGHT and steady."}',
);
const result = await generateBmiStatusForUser("user-1", { locale: "en" });
+115 -109
View File
@@ -10,8 +10,8 @@ vi.mock("@/lib/db", () => ({
},
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProvider: vi.fn(),
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
}));
vi.mock("@/lib/insights/memory", () => ({
@@ -20,21 +20,37 @@ vi.mock("@/lib/insights/memory", () => ({
}));
import { prisma } from "@/lib/db";
import { resolveProvider } from "@/lib/ai/provider";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { generateGeneralStatusForUser } from "../general-status";
const dayMs = 24 * 60 * 60 * 1000;
function stubCompletion(
content: string,
capture?: { userPrompt: string | null },
) {
vi.mocked(runStatusCompletion).mockImplementation(
async (args: { userPrompt: string }) => {
if (capture) capture.userPrompt = args.userPrompt;
return {
kind: "ok",
content,
providerType: "anthropic",
model: "x",
tokensUsed: 1,
} as never;
},
);
}
beforeEach(() => {
vi.resetAllMocks();
});
describe("generateGeneralStatusForUser — v1.4.6 bucketed payload", () => {
it("emits {daily, monthly} per metric series with dayOffset/monthOffset/value/n", async () => {
describe("generateGeneralStatusForUser — graded payload", () => {
it("emits a graded {recent, weekly, monthly} per-metric series, not the full daily array", async () => {
const now = new Date();
// 3 years of weight data — daily across the whole window so the
// bucketed output covers months 1-12 (daily) and 13-36 (monthly).
const weightRecords: Array<{
type: string;
value: number;
@@ -64,44 +80,62 @@ describe("generateGeneralStatusForUser — v1.4.6 bucketed payload", () => {
} as never);
const captured: { userPrompt: string | null } = { userPrompt: null };
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async (args: { userPrompt: string }) => {
captured.userPrompt = args.userPrompt;
return {
content: '{"summary":"OK"}',
model: "x",
tokensUsed: 1,
};
}),
} as never);
stubCompletion('{"summary":"OK"}', captured);
await generateGeneralStatusForUser("user-1", { locale: "en" });
expect(captured.userPrompt).not.toBeNull();
// The snapshot JSON is embedded in the user prompt as a fenced block.
const match = captured.userPrompt!.match(/\{[\s\S]*\}/);
expect(match).not.toBeNull();
const snapshot = JSON.parse(match![0]);
const weight = snapshot.measurementSeries.WEIGHT.series;
expect(weight).toHaveProperty("daily");
expect(weight).toHaveProperty("recent");
expect(weight).toHaveProperty("weekly");
expect(weight).toHaveProperty("monthly");
expect(Array.isArray(weight.daily)).toBe(true);
expect(Array.isArray(weight.monthly)).toBe(true);
expect(weight.daily.length).toBeGreaterThan(0);
expect(weight.monthly.length).toBeGreaterThan(0);
expect(weight.daily[0]).toHaveProperty("dayOffset");
expect(weight.daily[0]).toHaveProperty("value");
expect(weight.daily[0]).toHaveProperty("n");
expect(weight.monthly[0]).toHaveProperty("monthOffset");
expect(weight.monthly[0]).toHaveProperty("value");
expect(weight.monthly[0]).toHaveProperty("n");
expect(weight).toHaveProperty("yearly");
expect(weight.recent.length).toBeLessThanOrEqual(21);
expect(weight.recent[0]).toHaveProperty("date");
expect(weight.recent[0]).toHaveProperty("mean");
expect(weight.monthly[0]).toHaveProperty("month");
const total =
weight.recent.length +
weight.weekly.length +
weight.monthly.length +
weight.yearly.length;
expect(total).toBeLessThanOrEqual(50);
});
it("omits measurement types with no data", async () => {
const now = new Date();
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ type: "WEIGHT", value: 80, measuredAt: now },
] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: now,
} as never);
const captured: { userPrompt: string | null } = { userPrompt: null };
stubCompletion('{"summary":"OK"}', captured);
await generateGeneralStatusForUser("user-1", { locale: "en" });
const snapshot = JSON.parse(captured.userPrompt!.match(/\{[\s\S]*\}/)![0]);
// Only WEIGHT has data — no empty PULSE/BP/etc. series objects.
expect(Object.keys(snapshot.measurementSeries)).toEqual(["WEIGHT"]);
});
});
describe("generateGeneralStatusForUser — v1.4.41 timeout-stub persistence", () => {
it("persists a sentinel row keyed to today when the provider times out", async () => {
describe("generateGeneralStatusForUser — timeout/error never persists", () => {
it("serves the fallback without writing a cache row on timeout", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
} as never);
@@ -117,85 +151,63 @@ describe("generateGeneralStatusForUser — v1.4.41 timeout-stub persistence", ()
createdAt: new Date(),
} as never);
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(
() =>
new Promise(() => {
/* never resolves */
}),
),
} as never);
vi.useFakeTimers();
const promise = generateGeneralStatusForUser("user-1", { locale: "en" });
await vi.advanceTimersByTimeAsync(25_000);
const result = await promise;
vi.useRealTimers();
expect(result.text).toBeTruthy();
expect(result.cached).toBe(true);
expect(result.updatedAt).toBeTruthy();
const createCalls = vi.mocked(prisma.auditLog.create).mock.calls;
expect(createCalls.length).toBe(1);
const details = (createCalls[0][0] as { data: { details: string } }).data
.details;
const parsed = JSON.parse(details) as {
dateKey?: string;
text?: string;
timeout?: boolean;
model?: string;
};
expect(parsed.text).toBeTruthy();
expect(parsed.timeout).toBe(true);
expect(parsed.model).toBe("timeout-stub");
expect(parsed.dateKey).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
it("subsequent mounts short-circuit at the cache lookup and skip the race", async () => {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(new Date());
const y = parts.find((p) => p.type === "year")!.value;
const m = parts.find((p) => p.type === "month")!.value;
const d = parts.find((p) => p.type === "day")!.value;
const todayKey = `${y}-${m}-${d}`;
const stubRow = {
createdAt: new Date(),
details: JSON.stringify({
dateKey: todayKey,
locale: "en",
text: "General status fallback…",
timeout: true,
}),
};
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(stubRow as never);
const providerCall = vi.fn();
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: providerCall,
vi.mocked(runStatusCompletion).mockResolvedValue({
kind: "timeout",
} as never);
const result = await generateGeneralStatusForUser("user-1", {
locale: "en",
});
expect(providerCall).not.toHaveBeenCalled();
expect(result.text).toBe("General status fallback…");
expect(result.text).toBeTruthy();
expect(result.cached).toBe(true);
expect(result.updatedAt).toBe(stubRow.createdAt.toISOString());
expect(result.updatedAt).toBeNull();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
});
});
describe("generateGeneralStatusForUser — cache-read skips a stub", () => {
it("regenerates when the only cached row is a timeout stub", async () => {
const now = new Date();
const todayKey = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
}).format(now);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({
createdAt: now,
details: JSON.stringify({
dateKey: todayKey,
locale: "en",
text: "General status fallback…",
model: "timeout-stub",
timeout: true,
}),
} as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ type: "WEIGHT", value: 82, measuredAt: now },
] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: now,
} as never);
stubCompletion('{"summary":"Fresh general assessment."}');
const result = await generateGeneralStatusForUser("user-1", {
locale: "en",
});
expect(runStatusCompletion).toHaveBeenCalledTimes(1);
expect(result.text).toBe("Fresh general assessment.");
expect(result.cached).toBe(false);
});
});
describe("generateGeneralStatusForUser — token-leak hardening (v1.4.27 F16)", () => {
it("strips metric: tokens out of the cached text before persisting", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
@@ -213,15 +225,9 @@ describe("generateGeneralStatusForUser — token-leak hardening (v1.4.27 F16)",
createdAt: new Date(),
} as never);
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async () => ({
content:
'{"summary":"Weight trended down. metric:WEIGHT BP stable. metric:BLOOD_PRESSURE_SYS"}',
model: "x",
tokensUsed: 1,
})),
} as never);
stubCompletion(
'{"summary":"Weight trended down. metric:WEIGHT BP stable. metric:BLOOD_PRESSURE_SYS"}',
);
const result = await generateGeneralStatusForUser("user-1", {
locale: "en",
@@ -0,0 +1,131 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const findMany = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
measurement: {
findMany: (...args: unknown[]) => findMany(...args),
},
},
}));
const readBestGranularityRollups = vi.fn();
const rollupRow = (bucketStart: Date, mean: number) => ({
bucketStart,
count: 30,
mean,
sd: null,
slope: null,
r2: null,
sumValue: null,
minValue: mean - 1,
maxValue: mean + 1,
});
vi.mock("@/lib/rollups/measurement-read-wmy", () => ({
readBestGranularityRollups: (...args: unknown[]) =>
readBestGranularityRollups(...args),
// aggregateWmyBuckets is no longer imported by graded-series, but keep the
// module shape intact for any transitive consumer.
aggregateWmyBuckets: vi.fn(),
}));
import { buildGradedSeriesWithRollups } from "../graded-series";
const dayMs = 24 * 60 * 60 * 1000;
/** Daily readings spanning `days` back from `now`. */
function dailyRows(days: number, now: Date) {
const rows: Array<{ measuredAt: Date; value: number }> = [];
for (let i = 0; i < days; i++) {
rows.push({ measuredAt: new Date(now.getTime() - i * dayMs), value: 80 });
}
return rows;
}
afterEach(() => {
vi.clearAllMocks();
});
describe("buildGradedSeriesWithRollups — rollup-miss fallback", () => {
const now = new Date("2026-05-31T12:00:00Z");
it("folds monthly/yearly from the full history when the tier has no coverage", async () => {
// The bounded recent read (~90 d) returns only recent points; the
// full-history read returns 2+ years so the fold has monthly/yearly.
findMany
// 1st call — bounded recent read (gte: since)
.mockResolvedValueOnce(dailyRows(80, now))
// 2nd call — full-history fallback read (no gte)
.mockResolvedValueOnce(dailyRows(800, now));
// Tier miss: no MONTH / YEAR coverage at all.
readBestGranularityRollups.mockResolvedValue(null);
const series = await buildGradedSeriesWithRollups("u1", "WEIGHT", now);
// The bug: before the fix monthly/yearly were derived from the
// bounded ~90-day read and so were always empty even when years of
// raw history existed. After the fix they fold from the full history.
expect(series.monthly.length).toBeGreaterThan(0);
// 800 days of history puts at least one year into the yearly tail.
expect(series.yearly.length).toBeGreaterThan(0);
// The full-history fallback read must have been issued.
expect(findMany).toHaveBeenCalledTimes(2);
});
it("reads monthly/yearly from the tier when it has coverage and skips the full read", async () => {
findMany.mockResolvedValueOnce(dailyRows(80, now));
// MONTH coverage for the 1-year window, YEAR coverage for the 3-year.
readBestGranularityRollups.mockImplementation(
async (_u: string, _t: string, windowDays: number) => {
if (windowDays === 365) {
return {
granularity: "MONTH",
rows: [
rollupRow(new Date("2025-09-01T00:00:00Z"), 79),
rollupRow(new Date("2025-10-01T00:00:00Z"), 80),
],
};
}
return {
granularity: "YEAR",
rows: [rollupRow(new Date("2023-01-01T00:00:00Z"), 82)],
};
},
);
const series = await buildGradedSeriesWithRollups("u1", "WEIGHT", now);
expect(series.monthly.length).toBeGreaterThan(0);
expect(series.yearly.length).toBeGreaterThan(0);
// Only the bounded recent read — no full-history fallback when the
// tier covers both coarse slices.
expect(findMany).toHaveBeenCalledTimes(1);
});
it("falls back to a full read for only the slice the tier misses", async () => {
findMany
.mockResolvedValueOnce(dailyRows(80, now)) // bounded recent
.mockResolvedValueOnce(dailyRows(800, now)); // full-history fallback
// MONTH covered, YEAR missing.
readBestGranularityRollups.mockImplementation(
async (_u: string, _t: string, windowDays: number) => {
if (windowDays === 365) {
return {
granularity: "MONTH",
rows: [rollupRow(new Date("2025-10-01T00:00:00Z"), 80)],
};
}
return null;
},
);
const series = await buildGradedSeriesWithRollups("u1", "WEIGHT", now);
expect(series.monthly.length).toBeGreaterThan(0);
expect(series.yearly.length).toBeGreaterThan(0);
expect(findMany).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,114 @@
import { describe, expect, it } from "vitest";
import {
buildGradedSeriesFromPoints,
type GradedSeries,
} from "../graded-series";
const dayMs = 24 * 60 * 60 * 1000;
function dailyPoints(days: number, now: Date, value = (i: number) => 80 + (i % 5)) {
const out: Array<{ measuredAt: Date; value: number }> = [];
for (let i = 0; i < days; i++) {
out.push({ measuredAt: new Date(now.getTime() - i * dayMs), value: value(i) });
}
return out;
}
function countBuckets(s: GradedSeries): number {
return s.recent.length + s.weekly.length + s.monthly.length + s.yearly.length;
}
describe("buildGradedSeriesFromPoints", () => {
const now = new Date("2026-05-31T12:00:00Z");
it("keeps the last ~14-21 days at daily granularity", () => {
const s = buildGradedSeriesFromPoints(dailyPoints(400, now), now);
expect(s.recent.length).toBeGreaterThan(0);
expect(s.recent.length).toBeLessThanOrEqual(21);
// Each recent bucket carries daily aggregates.
for (const r of s.recent) {
expect(r).toHaveProperty("date");
expect(r).toHaveProperty("mean");
expect(r).toHaveProperty("min");
expect(r).toHaveProperty("max");
expect(r).toHaveProperty("n");
}
// Recent buckets are sorted oldest → newest.
const dates = s.recent.map((r) => r.date);
expect([...dates].sort()).toEqual(dates);
});
it("folds the weeks after the recent window into ~8-10 ISO-week buckets", () => {
const s = buildGradedSeriesFromPoints(dailyPoints(400, now), now);
expect(s.weekly.length).toBeGreaterThan(0);
expect(s.weekly.length).toBeLessThanOrEqual(12);
for (const w of s.weekly) {
expect(w).toHaveProperty("weekISO");
expect(w).toHaveProperty("mean");
expect(w).toHaveProperty("min");
expect(w).toHaveProperty("max");
expect(w).toHaveProperty("n");
}
});
it("folds older history into ~9-12 monthly buckets", () => {
const s = buildGradedSeriesFromPoints(dailyPoints(730, now), now);
expect(s.monthly.length).toBeGreaterThan(0);
expect(s.monthly.length).toBeLessThanOrEqual(14);
for (const m of s.monthly) {
expect(m).toHaveProperty("month");
expect(m).toHaveProperty("mean");
expect(m).toHaveProperty("min");
expect(m).toHaveProperty("max");
}
});
it("folds multi-year history into yearly buckets with a trend slope", () => {
// 3+ years so at least one year lands in the yearly bucket.
const s = buildGradedSeriesFromPoints(dailyPoints(1200, now), now);
expect(s.yearly.length).toBeGreaterThan(0);
for (const y of s.yearly) {
expect(y).toHaveProperty("year");
expect(y).toHaveProperty("mean");
expect(y).toHaveProperty("slope");
}
});
it("collapses a 2-year daily weigher into <= ~50 buckets, not 730", () => {
const s = buildGradedSeriesFromPoints(dailyPoints(730, now), now);
// ~21 recent + ~10 weekly + ~12 monthly + ~2-3 yearly. Worst-case
// boundary slack lands ~46; the point is the order-of-magnitude
// collapse from 730 daily readings, not an exact bucket count.
expect(countBuckets(s)).toBeLessThanOrEqual(50);
});
it("returns empty buckets for an empty input", () => {
const s = buildGradedSeriesFromPoints([], now);
expect(countBuckets(s)).toBe(0);
});
it("does not duplicate a day across recent and weekly", () => {
const s = buildGradedSeriesFromPoints(dailyPoints(120, now), now);
// The most recent ISO week of the recent window must not also be a
// weekly bucket — recent days are excluded from the weekly fold.
const recentDates = new Set(s.recent.map((r) => r.date));
// The weekly buckets cover days strictly older than the recent window.
expect(recentDates.size).toBe(s.recent.length);
expect(s.weekly.length).toBeGreaterThan(0);
});
it("computes per-bucket min/max/mean from same-day multi readings", () => {
const points = [
{ measuredAt: new Date(now.getTime()), value: 70 },
{ measuredAt: new Date(now.getTime() - 1000), value: 90 },
{ measuredAt: new Date(now.getTime() - 2000), value: 80 },
];
const s = buildGradedSeriesFromPoints(points, now);
expect(s.recent.length).toBe(1);
expect(s.recent[0].min).toBe(70);
expect(s.recent[0].max).toBe(90);
expect(s.recent[0].mean).toBe(80);
expect(s.recent[0].n).toBe(3);
});
});
@@ -0,0 +1,84 @@
/**
* v1.8.0 — measurement-driven per-metric assessment invalidation.
*
* A fresh measurement of a given type must drop the cached
* `insights.<scope>-status.<locale>` rows for the scopes that reading
* dirties, so the next mount / nightly warm pass regenerates them
* against the new data instead of serving the pre-measurement text for
* the rest of the day. These tests pin the type → scope mapping and the
* delete shape without a live DB.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
const deleteMany = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
auditLog: { deleteMany: (...a: unknown[]) => deleteMany(...a) },
},
}));
import { invalidateStatusInsightsForTypes } from "../comprehensive-generate";
beforeEach(() => {
vi.clearAllMocks();
deleteMany.mockResolvedValue({ count: 0 });
});
function deletedScopes(): string[] {
const arg = deleteMany.mock.calls[0][0];
// Each OR clause is `{ action: { startsWith: "insights.<scope>-status." } }`.
return (arg.where.OR as Array<{ action: { startsWith: string } }>)
.map((c) => c.action.startsWith.replace(/^insights\.(.+)-status\.$/, "$1"))
.sort();
}
describe("invalidateStatusInsightsForTypes", () => {
it("maps WEIGHT to weight + bmi + general", async () => {
await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]);
expect(deleteMany).toHaveBeenCalledTimes(1);
expect(deletedScopes()).toEqual(["bmi", "general", "weight"]);
expect(deleteMany.mock.calls[0][0].where.userId).toBe("u1");
});
it("maps both blood-pressure components to blood-pressure + general", async () => {
await invalidateStatusInsightsForTypes("u1", [
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
]);
expect(deletedScopes()).toEqual(["blood-pressure", "general"]);
});
it("maps PULSE and RESTING_HEART_RATE to pulse + general", async () => {
await invalidateStatusInsightsForTypes("u1", [
"PULSE",
"RESTING_HEART_RATE",
]);
expect(deletedScopes()).toEqual(["general", "pulse"]);
});
it("maps an unmapped metric to general only", async () => {
await invalidateStatusInsightsForTypes("u1", ["BLOOD_GLUCOSE"]);
expect(deletedScopes()).toEqual(["general"]);
});
it("dedupes scopes across a mixed batch", async () => {
await invalidateStatusInsightsForTypes("u1", [
"WEIGHT",
"PULSE",
"BLOOD_GLUCOSE",
]);
// weight + bmi + general (WEIGHT) ∪ pulse + general (PULSE) ∪ general
expect(deletedScopes()).toEqual([
"bmi",
"general",
"pulse",
"weight",
]);
});
it("is a no-op for an empty type set (no DB call)", async () => {
await invalidateStatusInsightsForTypes("u1", []);
expect(deleteMany).not.toHaveBeenCalled();
});
});
@@ -8,8 +8,8 @@ vi.mock("@/lib/db", () => ({
},
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProvider: vi.fn(),
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
}));
vi.mock("@/lib/insights/memory", () => ({
@@ -22,19 +22,50 @@ vi.mock("@/lib/medication-category", () => ({
}));
import { prisma } from "@/lib/db";
import { resolveProvider } from "@/lib/ai/provider";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { getMedicationCategories } from "@/lib/medication-category";
import { generateMedicationComplianceStatusForUser } from "../medication-compliance-status";
const dayMs = 24 * 60 * 60 * 1000;
function stubCompletion(
content: string,
capture?: { userPrompt: string | null },
) {
vi.mocked(runStatusCompletion).mockImplementation(
async (args: { userPrompt: string }) => {
if (capture) capture.userPrompt = args.userPrompt;
return {
kind: "ok",
content,
providerType: "anthropic",
model: "x",
tokensUsed: 1,
} as never;
},
);
}
function medFixture(now: Date) {
return {
id: "med-1",
name: "Ramipril",
dose: "5mg",
active: true,
createdAt: new Date(now.getTime() - 60 * dayMs),
schedules: [
{ id: "s1", windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null },
],
};
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getMedicationCategories).mockResolvedValue({});
});
describe("generateMedicationComplianceStatusForUser — v1.4.6 bucketed payload", () => {
it("emits {daily, monthly} compliance series per medication spanning 3 years", async () => {
describe("generateMedicationComplianceStatusForUser — graded payload", () => {
it("emits a graded {recent, weekly, monthly} compliance series per medication", async () => {
const now = new Date();
const medication = {
id: "med-1",
@@ -42,7 +73,9 @@ describe("generateMedicationComplianceStatusForUser — v1.4.6 bucketed payload"
dose: "5mg",
active: true,
createdAt: new Date(now.getTime() - 1100 * dayMs),
schedules: [{ id: "s1", windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null }],
schedules: [
{ id: "s1", windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null },
],
};
const events: Array<{
@@ -73,17 +106,7 @@ describe("generateMedicationComplianceStatusForUser — v1.4.6 bucketed payload"
} as never);
const captured: { userPrompt: string | null } = { userPrompt: null };
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async (args: { userPrompt: string }) => {
captured.userPrompt = args.userPrompt;
return {
content: '{"summary":"OK","medications":[]}',
model: "x",
tokensUsed: 1,
};
}),
} as never);
stubCompletion('{"summary":"OK","medications":[]}', captured);
await generateMedicationComplianceStatusForUser("user-1", {
locale: "en",
@@ -95,142 +118,92 @@ describe("generateMedicationComplianceStatusForUser — v1.4.6 bucketed payload"
expect(snapshot.medications).toBeInstanceOf(Array);
expect(snapshot.medications.length).toBe(1);
const dailySeries = snapshot.medications[0].dailySeries;
expect(dailySeries).toHaveProperty("daily");
expect(dailySeries).toHaveProperty("recent");
expect(dailySeries).toHaveProperty("weekly");
expect(dailySeries).toHaveProperty("monthly");
expect(dailySeries.daily.length).toBeGreaterThan(0);
expect(dailySeries.monthly.length).toBeGreaterThan(0);
expect(dailySeries.daily[0]).toHaveProperty("dayOffset");
expect(dailySeries.daily[0]).toHaveProperty("value");
expect(dailySeries.daily[0]).toHaveProperty("n");
expect(dailySeries.monthly[0]).toHaveProperty("monthOffset");
expect(dailySeries.monthly[0]).toHaveProperty("value");
expect(dailySeries.monthly[0]).toHaveProperty("n");
expect(dailySeries).toHaveProperty("yearly");
expect(dailySeries.recent.length).toBeLessThanOrEqual(21);
expect(dailySeries.recent[0]).toHaveProperty("date");
expect(dailySeries.recent[0]).toHaveProperty("mean");
expect(dailySeries.monthly[0]).toHaveProperty("month");
});
});
describe("generateMedicationComplianceStatusForUser — v1.4.41 timeout-stub persistence", () => {
it("persists a sentinel row keyed to today when the provider times out", async () => {
describe("generateMedicationComplianceStatusForUser — timeout never persists", () => {
it("serves the fallback summary without writing a cache row on timeout", async () => {
const now = new Date();
const medication = {
id: "med-1",
name: "Ramipril",
dose: "5mg",
active: true,
createdAt: new Date(now.getTime() - 60 * dayMs),
schedules: [{ id: "s1", windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null }],
};
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.medication.findMany).mockResolvedValue([
medication,
medFixture(now),
] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
createdAt: now,
} as never);
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(
() =>
new Promise(() => {
/* never resolves */
}),
),
} as never);
vi.useFakeTimers();
const promise = generateMedicationComplianceStatusForUser("user-1", {
locale: "en",
});
await vi.advanceTimersByTimeAsync(25_000);
const result = await promise;
vi.useRealTimers();
// Route returns the richer `{summary, medications}` shape — the
// helper writes the persist row and the route maps `summary` ←
// `text` on the way out.
expect(result.summary).toBeTruthy();
expect(result.medications).toEqual([]);
expect(result.cached).toBe(true);
expect(result.updatedAt).toBeTruthy();
const createCalls = vi.mocked(prisma.auditLog.create).mock.calls;
expect(createCalls.length).toBe(1);
const details = (createCalls[0][0] as { data: { details: string } }).data
.details;
const parsed = JSON.parse(details) as {
dateKey?: string;
text?: string;
timeout?: boolean;
model?: string;
};
expect(parsed.text).toBeTruthy();
expect(parsed.timeout).toBe(true);
expect(parsed.model).toBe("timeout-stub");
expect(parsed.dateKey).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
});
describe("generateMedicationComplianceStatusForUser — v1.4.41 timeout-stub short-circuit", () => {
it("subsequent mounts short-circuit when a stub row with text+timeout:true exists", async () => {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(new Date());
const y = parts.find((p) => p.type === "year")!.value;
const m = parts.find((p) => p.type === "month")!.value;
const d = parts.find((p) => p.type === "day")!.value;
const todayKey = `${y}-${m}-${d}`;
const stubRow = {
createdAt: new Date(),
details: JSON.stringify({
dateKey: todayKey,
locale: "en",
text: "Medication compliance fallback…",
timeout: true,
}),
};
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(stubRow as never);
const providerCall = vi.fn();
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: providerCall,
vi.mocked(runStatusCompletion).mockResolvedValue({
kind: "timeout",
} as never);
const result = await generateMedicationComplianceStatusForUser("user-1", {
locale: "en",
});
expect(providerCall).not.toHaveBeenCalled();
expect(result.summary).toBe("Medication compliance fallback…");
expect(result.summary).toBeTruthy();
expect(result.medications).toEqual([]);
expect(result.cached).toBe(true);
expect(result.updatedAt).toBe(stubRow.createdAt.toISOString());
expect(result.updatedAt).toBeNull();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
});
});
describe("generateMedicationComplianceStatusForUser — cache-read skips a stub", () => {
it("regenerates when the only cached row is a timeout stub", async () => {
const now = new Date();
const todayKey = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
}).format(now);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({
createdAt: now,
details: JSON.stringify({
dateKey: todayKey,
locale: "en",
text: "Medication compliance fallback…",
model: "timeout-stub",
timeout: true,
}),
} as never);
vi.mocked(prisma.medication.findMany).mockResolvedValue([
medFixture(now),
] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: now,
} as never);
stubCompletion('{"summary":"Fresh compliance assessment.","medications":[]}');
const result = await generateMedicationComplianceStatusForUser("user-1", {
locale: "en",
});
expect(runStatusCompletion).toHaveBeenCalledTimes(1);
expect(result.summary).toBe("Fresh compliance assessment.");
expect(result.cached).toBe(false);
});
});
describe("generateMedicationComplianceStatusForUser — token-leak hardening (v1.4.27 F16)", () => {
it("strips metric: tokens out of the cached summary + per-medication text", async () => {
it("strips metric: tokens out of the cached summary", async () => {
const now = new Date();
const medication = {
id: "med-1",
name: "Ramipril",
dose: "5mg",
active: true,
createdAt: new Date(now.getTime() - 60 * dayMs),
schedules: [{ id: "s1", windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null }],
};
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.medication.findMany).mockResolvedValue([
medication,
medFixture(now),
] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
@@ -239,24 +212,15 @@ describe("generateMedicationComplianceStatusForUser — token-leak hardening (v1
createdAt: new Date(),
} as never);
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async () => ({
content: JSON.stringify({
summary:
"Compliance held steady at 96%. metric:BLOOD_PRESSURE_SYS over the last 30 days.",
medications: [
{
medicationId: "med-1",
summary:
"Ramipril taken on schedule. metric:PULSE no missed doses in 30 days.",
},
],
}),
model: "x",
tokensUsed: 1,
})),
} as never);
// The compliance prompt returns only a `{ summary }` envelope; the
// per-medication cards are placeholder text built server-side, never
// model-authored. So token-leak hardening only has to scrub `summary`.
stubCompletion(
JSON.stringify({
summary:
"Compliance held steady at 96%. metric:BLOOD_PRESSURE_SYS over the last 30 days.",
}),
);
const result = await generateMedicationComplianceStatusForUser("user-1", {
locale: "en",
@@ -264,6 +228,9 @@ describe("generateMedicationComplianceStatusForUser — token-leak hardening (v1
expect(result.summary).toBeTruthy();
expect(result.summary).not.toContain("metric:");
// A per-medication placeholder row is still surfaced for the active med.
expect(result.medications).toHaveLength(1);
expect(result.medications[0].medicationId).toBe("med-1");
expect(result.medications[0].text).not.toContain("metric:");
const createCalls = vi.mocked(prisma.auditLog.create).mock.calls;
expect(createCalls.length).toBeGreaterThan(0);
+79 -109
View File
@@ -8,8 +8,8 @@ vi.mock("@/lib/db", () => ({
},
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProvider: vi.fn(),
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
}));
vi.mock("@/lib/insights/memory", () => ({
@@ -18,17 +18,35 @@ vi.mock("@/lib/insights/memory", () => ({
}));
import { prisma } from "@/lib/db";
import { resolveProvider } from "@/lib/ai/provider";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { generateMoodStatusForUser } from "../mood-status";
const dayMs = 24 * 60 * 60 * 1000;
function stubCompletion(
content: string,
capture?: { userPrompt: string | null },
) {
vi.mocked(runStatusCompletion).mockImplementation(
async (args: { userPrompt: string }) => {
if (capture) capture.userPrompt = args.userPrompt;
return {
kind: "ok",
content,
providerType: "anthropic",
model: "x",
tokensUsed: 1,
} as never;
},
);
}
beforeEach(() => {
vi.resetAllMocks();
});
describe("generateMoodStatusForUser — v1.4.6 bucketed payload", () => {
it("emits {daily, monthly} mood series spanning 3 years", async () => {
describe("generateMoodStatusForUser — graded payload", () => {
it("emits a graded {recent, weekly, monthly} mood series, not the full daily array", async () => {
const now = new Date();
const entries: Array<{
date: string;
@@ -39,9 +57,6 @@ describe("generateMoodStatusForUser — v1.4.6 bucketed payload", () => {
for (let day = 0; day < 1000; day++) {
const t = new Date(now.getTime() - day * dayMs);
entries.push({
// production code keys mood buckets by `moodLoggedAt`, so the
// `date` string here is irrelevant for bucketing — keep it
// ISO-shaped so other downstream consumers don't choke.
date: t.toISOString().slice(0, 10),
score: 3 + (day % 3) * 0.5,
tags: [],
@@ -57,17 +72,7 @@ describe("generateMoodStatusForUser — v1.4.6 bucketed payload", () => {
} as never);
const captured: { userPrompt: string | null } = { userPrompt: null };
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async (args: { userPrompt: string }) => {
captured.userPrompt = args.userPrompt;
return {
content: '{"summary":"OK"}',
model: "x",
tokensUsed: 1,
};
}),
} as never);
stubCompletion('{"summary":"OK"}', captured);
await generateMoodStatusForUser("user-1", { locale: "en" });
@@ -75,110 +80,82 @@ describe("generateMoodStatusForUser — v1.4.6 bucketed payload", () => {
const snapshot = JSON.parse(match![0]);
const mood = snapshot.mood.series;
expect(mood).toHaveProperty("daily");
expect(mood).toHaveProperty("recent");
expect(mood).toHaveProperty("weekly");
expect(mood).toHaveProperty("monthly");
expect(mood.daily.length).toBeGreaterThan(0);
expect(mood.monthly.length).toBeGreaterThan(0);
expect(mood.daily[0]).toHaveProperty("dayOffset");
expect(mood.daily[0]).toHaveProperty("value");
expect(mood.daily[0]).toHaveProperty("n");
expect(mood.monthly[0]).toHaveProperty("monthOffset");
expect(mood.monthly[0]).toHaveProperty("value");
expect(mood.monthly[0]).toHaveProperty("n");
expect(mood).toHaveProperty("yearly");
expect(mood.recent.length).toBeLessThanOrEqual(21);
expect(mood.recent[0]).toHaveProperty("date");
expect(mood.recent[0]).toHaveProperty("mean");
expect(mood.monthly[0]).toHaveProperty("month");
const total =
mood.recent.length +
mood.weekly.length +
mood.monthly.length +
mood.yearly.length;
expect(total).toBeLessThanOrEqual(50);
});
});
describe("generateMoodStatusForUser — v1.4.41 timeout-stub persistence", () => {
it("persists a sentinel row keyed to today when the provider times out", async () => {
describe("generateMoodStatusForUser — timeout/error never persists", () => {
it("serves the fallback without writing a cache row on timeout", async () => {
const t = new Date();
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([
{
date: t.toISOString().slice(0, 10),
score: 4,
tags: [],
moodLoggedAt: t,
},
{ date: t.toISOString().slice(0, 10), score: 4, tags: [], moodLoggedAt: t },
] as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(
() =>
new Promise(() => {
/* never resolves */
}),
),
} as never);
vi.useFakeTimers();
const promise = generateMoodStatusForUser("user-1", { locale: "en" });
await vi.advanceTimersByTimeAsync(25_000);
const result = await promise;
vi.useRealTimers();
expect(result.text).toBeTruthy();
expect(result.cached).toBe(true);
expect(result.updatedAt).toBeTruthy();
const createCalls = vi.mocked(prisma.auditLog.create).mock.calls;
expect(createCalls.length).toBe(1);
const details = (createCalls[0][0] as { data: { details: string } }).data
.details;
const parsed = JSON.parse(details) as {
dateKey?: string;
text?: string;
timeout?: boolean;
model?: string;
};
expect(parsed.text).toBeTruthy();
expect(parsed.timeout).toBe(true);
expect(parsed.model).toBe("timeout-stub");
expect(parsed.dateKey).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
it("subsequent mounts short-circuit at the cache lookup and skip the race", async () => {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(new Date());
const y = parts.find((p) => p.type === "year")!.value;
const m = parts.find((p) => p.type === "month")!.value;
const d = parts.find((p) => p.type === "day")!.value;
const todayKey = `${y}-${m}-${d}`;
const stubRow = {
createdAt: new Date(),
details: JSON.stringify({
dateKey: todayKey,
locale: "en",
text: "Mood fallback text…",
timeout: true,
}),
};
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(stubRow as never);
const providerCall = vi.fn();
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: providerCall,
vi.mocked(runStatusCompletion).mockResolvedValue({
kind: "timeout",
} as never);
const result = await generateMoodStatusForUser("user-1", { locale: "en" });
expect(providerCall).not.toHaveBeenCalled();
expect(result.text).toBe("Mood fallback text…");
expect(result.text).toBeTruthy();
expect(result.cached).toBe(true);
expect(result.updatedAt).toBe(stubRow.createdAt.toISOString());
expect(result.updatedAt).toBeNull();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
});
});
describe("generateMoodStatusForUser — cache-read skips a stub", () => {
it("regenerates when the only cached row is a timeout stub", async () => {
const now = new Date();
const todayKey = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
}).format(now);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({
createdAt: now,
details: JSON.stringify({
dateKey: todayKey,
locale: "en",
text: "Mood fallback text…",
model: "timeout-stub",
timeout: true,
}),
} as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([
{ date: now.toISOString().slice(0, 10), score: 4, tags: [], moodLoggedAt: now },
] as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: now,
} as never);
stubCompletion('{"summary":"Fresh mood assessment."}');
const result = await generateMoodStatusForUser("user-1", { locale: "en" });
expect(runStatusCompletion).toHaveBeenCalledTimes(1);
expect(result.text).toBe("Fresh mood assessment.");
expect(result.cached).toBe(false);
});
});
describe("generateMoodStatusForUser — token-leak hardening (v1.4.27 F16)", () => {
it("strips metric: tokens out of the cached text before persisting", async () => {
const t = new Date();
@@ -191,14 +168,7 @@ describe("generateMoodStatusForUser — token-leak hardening (v1.4.27 F16)", ()
createdAt: new Date(),
} as never);
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async () => ({
content: '{"summary":"Mood stayed positive. metric:MOOD"}',
model: "x",
tokensUsed: 1,
})),
} as never);
stubCompletion('{"summary":"Mood stayed positive. metric:MOOD"}');
const result = await generateMoodStatusForUser("user-1", { locale: "en" });
@@ -0,0 +1,139 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
/**
* Wave 1D — payload-budget verification. A dense daily weigher (2 years
* of daily WEIGHT + BP, plus a many-type Apple-Health account for
* general-status) used to embed the full ~360-day daily array per
* metric, running ~20-100 K tokens. After the graded-series rewrite the
* snapshot the prompt carries must sit well under the ~24 000-char
* (~6 000-token) cap.
*
* The snapshot JSON is the `{ … }` block embedded in the user prompt;
* `runStatusCompletion` is stubbed to capture it.
*/
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
auditLog: { findFirst: vi.fn(), create: vi.fn() },
measurement: { findMany: vi.fn() },
measurementRollup: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
},
}));
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
}));
vi.mock("@/lib/insights/memory", () => ({
getPreviousInsightContext: vi.fn().mockResolvedValue(null),
formatPreviousContextForPrompt: vi.fn().mockReturnValue(""),
}));
import { prisma } from "@/lib/db";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { generateWeightStatusForUser } from "../weight-status";
import { generateGeneralStatusForUser } from "../general-status";
const dayMs = 24 * 60 * 60 * 1000;
const BUDGET_CHARS = 24_000;
let capturedPrompt: string | null = null;
function stubCapture() {
vi.mocked(runStatusCompletion).mockImplementation(
async (args: { userPrompt: string }) => {
capturedPrompt = args.userPrompt;
return {
kind: "ok",
content: '{"summary":"OK"}',
providerType: "anthropic",
model: "x",
tokensUsed: 1,
} as never;
},
);
}
function snapshotChars(): number {
const match = capturedPrompt!.match(/\{[\s\S]*\}/);
return match![0].length;
}
beforeEach(() => {
vi.resetAllMocks();
capturedPrompt = null;
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
gender: null,
heightCm: 175,
} as never);
stubCapture();
});
describe("status snapshot payload budget", () => {
it("weight-status: a 2-year daily weigher + daily BP stays under the char cap", async () => {
const now = new Date();
const rows: Array<{ type: string; value: number; measuredAt: Date }> = [];
for (let day = 0; day < 730; day++) {
const measuredAt = new Date(now.getTime() - day * dayMs);
rows.push({ type: "WEIGHT", value: 80 + (day % 5), measuredAt });
rows.push({
type: "BLOOD_PRESSURE_SYS",
value: 120 + (day % 10),
measuredAt,
});
rows.push({
type: "BLOOD_PRESSURE_DIA",
value: 80 + (day % 5),
measuredAt,
});
}
vi.mocked(prisma.measurement.findMany).mockResolvedValue(rows as never);
await generateWeightStatusForUser("user-1", { locale: "en" });
const chars = snapshotChars();
// Pre-rewrite this ran ~80-110 KB for this exact account; the
// graded rewrite lands it around ~15 KB.
expect(chars).toBeLessThanOrEqual(BUDGET_CHARS);
});
it("general-status: a many-type dense account stays under the char cap", async () => {
const now = new Date();
const types = [
"WEIGHT",
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
"PULSE",
"BODY_FAT",
"STEPS",
];
const rows: Array<{ type: string; value: number; measuredAt: Date }> = [];
for (let day = 0; day < 730; day++) {
const measuredAt = new Date(now.getTime() - day * dayMs);
for (const type of types) {
rows.push({ type, value: 50 + (day % 30), measuredAt });
}
}
vi.mocked(prisma.measurement.findMany).mockResolvedValue(rows as never);
await generateGeneralStatusForUser("user-1", { locale: "en" });
const chars = snapshotChars();
// Pre-rewrite a many-type dense account could exceed 100 K tokens;
// the graded rewrite lands it around ~12 KB.
expect(chars).toBeLessThanOrEqual(BUDGET_CHARS);
});
});
@@ -1,19 +1,15 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { describe, expect, it, vi, beforeEach } from "vitest";
/**
* v1.4.28 R3a FB-D2 — provider timeout fallback for the pulse-status
* route. When the upstream `generateCompletion` call does not resolve
* inside the 20-second budget the route returns a deterministic
* cached-style envelope so the InsightStatusCard renders the
* fallback text instead of spinning behind React-Query's default
* retries.
*
* v1.4.41 — the timeout branch now persists a sentinel keyed to
* today (see `persistTimeoutStubAndReturn`) so subsequent mounts
* short-circuit at the cache lookup. The persisted row carries
* `timeout: true` + `model: "timeout-stub"` so the daily pre-warm
* job can recognise and overwrite the stub instead of treating it
* as a real assessment.
* Provider-timeout fallback for the pulse-status route. When the
* provider chain does not resolve inside `STATUS_PROVIDER_TIMEOUT_MS`
* the route returns the deterministic no-key fallback so the
* InsightStatusCard renders instead of spinning — but it must NOT
* persist the fallback. The earlier behaviour cached a `timeout-stub`
* row keyed to today; that stub stuck until midnight and hid the real
* data-driven assessment. The fix treats a timeout as a transient miss:
* serve the fallback for this render, write nothing, re-attempt next
* mount.
*/
vi.mock("@/lib/db", () => ({
@@ -21,12 +17,13 @@ vi.mock("@/lib/db", () => ({
user: { findUnique: vi.fn() },
auditLog: { findFirst: vi.fn(), create: vi.fn() },
measurement: { findMany: vi.fn() },
measurementRollup: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
},
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProvider: vi.fn(),
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
}));
vi.mock("@/lib/insights/memory", () => ({
@@ -35,20 +32,16 @@ vi.mock("@/lib/insights/memory", () => ({
}));
import { prisma } from "@/lib/db";
import { resolveProvider } from "@/lib/ai/provider";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { generatePulseStatusForUser } from "../pulse-status";
beforeEach(() => {
vi.resetAllMocks();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never);
});
describe("generatePulseStatusForUser — provider timeout fallback", () => {
it("returns a cached-style envelope when the provider exceeds 20 s", async () => {
it("returns the fallback without persisting a cache row on timeout", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
gender: null,
@@ -58,40 +51,21 @@ describe("generatePulseStatusForUser — provider timeout fallback", () => {
{ value: 72, measuredAt: new Date() },
] as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
vi.mocked(runStatusCompletion).mockResolvedValue({
kind: "timeout",
} as never);
// Provider stalls indefinitely.
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(
() => new Promise<{ content: string }>(() => {}),
),
} as never);
const promise = generatePulseStatusForUser("user-1", { locale: "en" });
// Advance past the 20-second budget so the timeout race wins.
await vi.advanceTimersByTimeAsync(21_000);
const result = await promise;
const result = await generatePulseStatusForUser("user-1", {
locale: "en",
});
expect(result.hasProvider).toBe(true);
expect(result.cached).toBe(true);
expect(typeof result.text).toBe("string");
expect(result.text?.length ?? 0).toBeGreaterThan(0);
// v1.4.41 — the timeout branch now persists a sentinel row so
// subsequent mounts short-circuit at the cache lookup. The
// daily pre-warm worker overwrites the stub by detecting
// `timeout: true` + `model: "timeout-stub"`.
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const createArg = vi.mocked(prisma.auditLog.create).mock.calls[0][0] as {
data: { details: string };
};
const parsed = JSON.parse(createArg.data.details) as {
timeout?: boolean;
model?: string;
};
expect(parsed.timeout).toBe(true);
expect(parsed.model).toBe("timeout-stub");
// No persisted row — the sticky-stub bug is gone.
expect(result.updatedAt).toBeNull();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
});
});
+70 -69
View File
@@ -5,12 +5,13 @@ vi.mock("@/lib/db", () => ({
user: { findUnique: vi.fn() },
auditLog: { findFirst: vi.fn(), create: vi.fn() },
measurement: { findMany: vi.fn() },
measurementRollup: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
},
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProvider: vi.fn(),
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
}));
vi.mock("@/lib/insights/memory", () => ({
@@ -19,17 +20,38 @@ vi.mock("@/lib/insights/memory", () => ({
}));
import { prisma } from "@/lib/db";
import { resolveProvider } from "@/lib/ai/provider";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { generatePulseStatusForUser } from "../pulse-status";
const dayMs = 24 * 60 * 60 * 1000;
function stubCompletion(
content: string,
capture?: { userPrompt: string | null },
) {
vi.mocked(runStatusCompletion).mockImplementation(
async (args: { userPrompt: string }) => {
if (capture) capture.userPrompt = args.userPrompt;
return {
kind: "ok",
content,
providerType: "anthropic",
model: "x",
tokensUsed: 1,
} as never;
},
);
}
beforeEach(() => {
vi.resetAllMocks();
// Cold rollup tier: the pulse graded series folds monthly/yearly from
// the full-history `measurement.findMany` fallback on a tier miss.
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never);
});
describe("generatePulseStatusForUser — v1.4.6 bucketed payload", () => {
it("emits {daily, monthly} pulse series with dayOffset/monthOffset/value/n", async () => {
describe("generatePulseStatusForUser — graded payload", () => {
it("emits a graded {recent, weekly, monthly} pulse series, not the full daily array", async () => {
const now = new Date();
const records: Array<{ value: number; measuredAt: Date }> = [];
for (let day = 0; day < 1000; day++) {
@@ -51,17 +73,7 @@ describe("generatePulseStatusForUser — v1.4.6 bucketed payload", () => {
} as never);
const captured: { userPrompt: string | null } = { userPrompt: null };
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async (args: { userPrompt: string }) => {
captured.userPrompt = args.userPrompt;
return {
content: '{"summary":"OK"}',
model: "x",
tokensUsed: 1,
};
}),
} as never);
stubCompletion('{"summary":"OK"}', captured);
await generatePulseStatusForUser("user-1", { locale: "en" });
@@ -69,63 +81,58 @@ describe("generatePulseStatusForUser — v1.4.6 bucketed payload", () => {
const snapshot = JSON.parse(match![0]);
const pulse = snapshot.pulse.series;
expect(pulse).toHaveProperty("daily");
expect(pulse).toHaveProperty("recent");
expect(pulse).toHaveProperty("weekly");
expect(pulse).toHaveProperty("monthly");
expect(pulse.daily.length).toBeGreaterThan(0);
expect(pulse.monthly.length).toBeGreaterThan(0);
expect(pulse.daily[0]).toHaveProperty("dayOffset");
expect(pulse.daily[0]).toHaveProperty("value");
expect(pulse.daily[0]).toHaveProperty("n");
expect(pulse.monthly[0]).toHaveProperty("monthOffset");
expect(pulse.monthly[0]).toHaveProperty("value");
expect(pulse.monthly[0]).toHaveProperty("n");
expect(pulse).toHaveProperty("yearly");
expect(pulse.recent.length).toBeLessThanOrEqual(21);
expect(pulse.recent[0]).toHaveProperty("date");
expect(pulse.recent[0]).toHaveProperty("mean");
expect(pulse.monthly[0]).toHaveProperty("month");
const total =
pulse.recent.length +
pulse.weekly.length +
pulse.monthly.length +
pulse.yearly.length;
expect(total).toBeLessThanOrEqual(50);
});
});
describe("generatePulseStatusForUser — v1.4.41 timeout-stub short-circuit", () => {
// The persist-on-timeout path is covered by
// `pulse-status-timeout.test.ts`. This block pins the second-mount
// behaviour: when a sentinel row already exists for today the cache
// lookup short-circuits and the provider is never invoked.
it("subsequent mounts short-circuit at the cache lookup and skip the race", async () => {
const parts = new Intl.DateTimeFormat("en-US", {
describe("generatePulseStatusForUser — cache-read skips a stub", () => {
it("regenerates when the only cached row is a timeout stub", async () => {
const now = new Date();
const todayKey = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(new Date());
const y = parts.find((p) => p.type === "year")!.value;
const m = parts.find((p) => p.type === "month")!.value;
const d = parts.find((p) => p.type === "day")!.value;
const todayKey = `${y}-${m}-${d}`;
const stubRow = {
createdAt: new Date(),
details: JSON.stringify({
dateKey: todayKey,
locale: "en",
text: "Pulse fallback text…",
timeout: true,
}),
};
}).format(now);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
gender: null,
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(stubRow as never);
const providerCall = vi.fn();
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: providerCall,
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({
createdAt: now,
details: JSON.stringify({
dateKey: todayKey,
locale: "en",
text: "Pulse fallback text…",
model: "timeout-stub",
timeout: true,
}),
} as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ value: 72, measuredAt: now },
] as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: now,
} as never);
stubCompletion('{"summary":"Fresh pulse assessment."}');
const result = await generatePulseStatusForUser("user-1", { locale: "en" });
expect(providerCall).not.toHaveBeenCalled();
expect(result.text).toBe("Pulse fallback text…");
expect(result.cached).toBe(true);
expect(result.updatedAt).toBe(stubRow.createdAt.toISOString());
expect(prisma.auditLog.create).not.toHaveBeenCalled();
expect(runStatusCompletion).toHaveBeenCalledTimes(1);
expect(result.text).toBe("Fresh pulse assessment.");
expect(result.cached).toBe(false);
});
});
@@ -144,15 +151,9 @@ describe("generatePulseStatusForUser — token-leak hardening (v1.4.27 F16)", ()
createdAt: new Date(),
} as never);
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async () => ({
content:
'{"summary":"Your pulse is stable. metric:PULSE The 7-day average sits inside the band."}',
model: "x",
tokensUsed: 1,
})),
} as never);
stubCompletion(
'{"summary":"Your pulse is stable. metric:PULSE The 7-day average sits inside the band."}',
);
const result = await generatePulseStatusForUser("user-1", { locale: "en" });
@@ -0,0 +1,123 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
auditLog: { findFirst: vi.fn() },
},
}));
import { prisma } from "@/lib/db";
import { isTimeoutStub, readFreshStatusText } from "../status-cache";
const TODAY = "2026-05-31";
function cacheRow(details: Record<string, unknown>, createdAt = new Date()) {
return { createdAt, details: JSON.stringify(details) };
}
beforeEach(() => {
vi.resetAllMocks();
});
describe("isTimeoutStub", () => {
it("flags the timeout-stub model marker", () => {
expect(isTimeoutStub({ model: "timeout-stub" })).toBe(true);
});
it("flags the timeout:true marker", () => {
expect(isTimeoutStub({ timeout: true })).toBe(true);
});
it("does not flag a real assessment", () => {
expect(isTimeoutStub({ model: "gpt-4o-mini", timeout: false })).toBe(false);
expect(isTimeoutStub({})).toBe(false);
});
});
describe("readFreshStatusText", () => {
it("returns today's real assessment text", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(
cacheRow({
dateKey: TODAY,
text: "Your weight trend is stable.",
model: "gpt-4o-mini",
}) as never,
);
const hit = await readFreshStatusText({
userId: "u1",
cacheAction: "insights.weight-status.en",
todayKey: TODAY,
force: false,
});
expect(hit?.text).toBe("Your weight trend is stable.");
});
it("skips a timeout-stub row keyed to today", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(
cacheRow({
dateKey: TODAY,
text: "Generic fallback advice.",
model: "timeout-stub",
timeout: true,
}) as never,
);
const hit = await readFreshStatusText({
userId: "u1",
cacheAction: "insights.weight-status.en",
todayKey: TODAY,
force: false,
});
expect(hit).toBeNull();
});
it("skips a stale-day row", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(
cacheRow({ dateKey: "2026-05-30", text: "Yesterday." }) as never,
);
const hit = await readFreshStatusText({
userId: "u1",
cacheAction: "insights.weight-status.en",
todayKey: TODAY,
force: false,
});
expect(hit).toBeNull();
});
it("skips an empty-text row", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(
cacheRow({ dateKey: TODAY, text: " " }) as never,
);
const hit = await readFreshStatusText({
userId: "u1",
cacheAction: "insights.weight-status.en",
todayKey: TODAY,
force: false,
});
expect(hit).toBeNull();
});
it("does not read the cache under force", async () => {
const hit = await readFreshStatusText({
userId: "u1",
cacheAction: "insights.weight-status.en",
todayKey: TODAY,
force: true,
});
expect(hit).toBeNull();
expect(prisma.auditLog.findFirst).not.toHaveBeenCalled();
});
it("treats a malformed payload as a miss", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({
createdAt: new Date(),
details: "{not json",
} as never);
const hit = await readFreshStatusText({
userId: "u1",
cacheAction: "insights.weight-status.en",
todayKey: TODAY,
force: false,
});
expect(hit).toBeNull();
});
});
+92 -102
View File
@@ -4,12 +4,13 @@ vi.mock("@/lib/db", () => ({
prisma: {
auditLog: { findFirst: vi.fn(), create: vi.fn() },
measurement: { findMany: vi.fn() },
measurementRollup: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
},
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProvider: vi.fn(),
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
}));
vi.mock("@/lib/insights/memory", () => ({
@@ -18,17 +19,38 @@ vi.mock("@/lib/insights/memory", () => ({
}));
import { prisma } from "@/lib/db";
import { resolveProvider } from "@/lib/ai/provider";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { generateWeightStatusForUser } from "../weight-status";
const dayMs = 24 * 60 * 60 * 1000;
function stubCompletion(
content: string,
capture?: { userPrompt: string | null },
) {
vi.mocked(runStatusCompletion).mockImplementation(
async (args: { userPrompt: string }) => {
if (capture) capture.userPrompt = args.userPrompt;
return {
kind: "ok",
content,
providerType: "anthropic",
model: "x",
tokensUsed: 1,
} as never;
},
);
}
beforeEach(() => {
vi.resetAllMocks();
// Cold rollup tier: the graded builder folds monthly/yearly from the
// full-history `measurement.findMany` fallback the test already mocks.
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never);
});
describe("generateWeightStatusForUser — v1.4.6 bucketed payload", () => {
it("emits {daily, monthly} weight series with bucket fields", async () => {
describe("generateWeightStatusForUser — graded payload", () => {
it("emits a graded {recent, weekly, monthly} weight series, not the full daily array", async () => {
const now = new Date();
const records: Array<{
type: string;
@@ -51,17 +73,7 @@ describe("generateWeightStatusForUser — v1.4.6 bucketed payload", () => {
} as never);
const captured: { userPrompt: string | null } = { userPrompt: null };
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async (args: { userPrompt: string }) => {
captured.userPrompt = args.userPrompt;
return {
content: '{"summary":"OK"}',
model: "x",
tokensUsed: 1,
};
}),
} as never);
stubCompletion('{"summary":"OK"}', captured);
await generateWeightStatusForUser("user-1", { locale: "en" });
@@ -69,21 +81,31 @@ describe("generateWeightStatusForUser — v1.4.6 bucketed payload", () => {
const snapshot = JSON.parse(match![0]);
const weight = snapshot.weight.series;
expect(weight).toHaveProperty("daily");
expect(weight).toHaveProperty("recent");
expect(weight).toHaveProperty("weekly");
expect(weight).toHaveProperty("monthly");
expect(weight.daily.length).toBeGreaterThan(0);
expect(weight.monthly.length).toBeGreaterThan(0);
expect(weight.daily[0]).toHaveProperty("dayOffset");
expect(weight.daily[0]).toHaveProperty("value");
expect(weight.daily[0]).toHaveProperty("n");
expect(weight.monthly[0]).toHaveProperty("monthOffset");
expect(weight.monthly[0]).toHaveProperty("value");
expect(weight.monthly[0]).toHaveProperty("n");
expect(weight).toHaveProperty("yearly");
// No raw daily array beyond the bounded recent window.
expect(weight.recent.length).toBeLessThanOrEqual(21);
expect(weight.recent[0]).toHaveProperty("date");
expect(weight.recent[0]).toHaveProperty("mean");
expect(weight.recent[0]).toHaveProperty("min");
expect(weight.recent[0]).toHaveProperty("max");
expect(weight.monthly[0]).toHaveProperty("month");
expect(weight.monthly[0]).toHaveProperty("mean");
// The whole graded series collapses 1000 daily readings to a tiny
// bucket count.
const total =
weight.recent.length +
weight.weekly.length +
weight.monthly.length +
weight.yearly.length;
expect(total).toBeLessThanOrEqual(50);
});
});
describe("generateWeightStatusForUser — v1.4.41 timeout-stub persistence", () => {
it("persists a sentinel row keyed to today when the provider times out", async () => {
describe("generateWeightStatusForUser — timeout/error never persists", () => {
it("serves the fallback without writing a cache row on timeout", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ type: "WEIGHT", value: 82, measuredAt: new Date() },
@@ -93,83 +115,57 @@ describe("generateWeightStatusForUser — v1.4.41 timeout-stub persistence", ()
createdAt: new Date(),
} as never);
// Provider stalls indefinitely so the timeout race wins.
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(
() =>
new Promise(() => {
/* never resolves */
}),
),
} as never);
vi.useFakeTimers();
const promise = generateWeightStatusForUser("user-1", { locale: "en" });
await vi.advanceTimersByTimeAsync(25_000);
const result = await promise;
vi.useRealTimers();
expect(result.text).toBeTruthy();
expect(result.cached).toBe(true);
expect(result.updatedAt).toBeTruthy();
const createCalls = vi.mocked(prisma.auditLog.create).mock.calls;
expect(createCalls.length).toBe(1);
const details = (createCalls[0][0] as { data: { details: string } }).data
.details;
const parsed = JSON.parse(details) as {
dateKey?: string;
text?: string;
timeout?: boolean;
model?: string;
};
expect(parsed.text).toBeTruthy();
expect(parsed.timeout).toBe(true);
expect(parsed.model).toBe("timeout-stub");
expect(parsed.dateKey).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
it("subsequent mounts short-circuit at the cache lookup and skip the race", async () => {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(new Date());
const y = parts.find((p) => p.type === "year")!.value;
const m = parts.find((p) => p.type === "month")!.value;
const d = parts.find((p) => p.type === "day")!.value;
const todayKey = `${y}-${m}-${d}`;
const stubRow = {
createdAt: new Date(),
details: JSON.stringify({
dateKey: todayKey,
locale: "en",
text: "Weight is a directional metric…",
timeout: true,
}),
};
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(stubRow as never);
const providerCall = vi.fn();
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: providerCall,
vi.mocked(runStatusCompletion).mockResolvedValue({
kind: "timeout",
} as never);
const result = await generateWeightStatusForUser("user-1", {
locale: "en",
});
expect(providerCall).not.toHaveBeenCalled();
expect(result.text).toBe("Weight is a directional metric…");
expect(result.text).toBeTruthy();
expect(result.cached).toBe(true);
expect(result.updatedAt).toBe(stubRow.createdAt.toISOString());
expect(result.updatedAt).toBeNull();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
});
});
describe("generateWeightStatusForUser — cache-read skips a stub", () => {
it("regenerates when the only cached row is a timeout stub", async () => {
const now = new Date();
const todayKey = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
}).format(now);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({
createdAt: now,
details: JSON.stringify({
dateKey: todayKey,
locale: "en",
text: "Generic stub fallback.",
model: "timeout-stub",
timeout: true,
}),
} as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ type: "WEIGHT", value: 82, measuredAt: now },
] as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: now,
} as never);
stubCompletion('{"summary":"Fresh real assessment."}');
const result = await generateWeightStatusForUser("user-1", {
locale: "en",
});
expect(runStatusCompletion).toHaveBeenCalledTimes(1);
expect(result.text).toBe("Fresh real assessment.");
expect(result.cached).toBe(false);
});
});
describe("generateWeightStatusForUser — token-leak hardening (v1.4.27 F16)", () => {
it("strips metric: tokens out of the cached text before persisting", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
@@ -181,15 +177,9 @@ describe("generateWeightStatusForUser — token-leak hardening (v1.4.27 F16)", (
createdAt: new Date(),
} as never);
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async () => ({
content:
'{"summary":"Weight trended down 0.4 kg last week. metric:WEIGHT"}',
model: "x",
tokensUsed: 1,
})),
} as never);
stubCompletion(
'{"summary":"Weight trended down 0.4 kg last week. metric:WEIGHT"}',
);
const result = await generateWeightStatusForUser("user-1", {
locale: "en",
+101 -153
View File
@@ -1,5 +1,4 @@
import { prisma } from "@/lib/db";
import { resolveProvider } from "@/lib/ai/provider";
import {
getBloodPressureSystemPrompt,
getBloodPressureUserPrompt,
@@ -28,59 +27,32 @@ import {
dayOffsetToBerlinDayKey,
type DailyBucket,
} from "@/lib/insights/bucket-series";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import {
withTimeout,
STATUS_PROVIDER_TIMEOUT_MS,
} from "@/lib/insights/with-timeout";
import { persistTimeoutStubAndReturn } from "@/lib/insights/persist-timeout-stub";
buildGradedSeriesFromPoints,
buildGradedSeriesWithRollups,
degradeStatusSnapshotToBudget,
} from "@/lib/insights/graded-series";
import {
type SupportedLocale,
normalizeLocale,
normalizeSummaryText,
parseSummaryFromContent,
persistStatusInsight,
round,
summarizeSeries,
} from "@/lib/insights/status-shared";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { readFreshStatusText } from "@/lib/insights/status-cache";
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
import { annotate } from "@/lib/logging/context";
import { toBerlinDayKey } from "@/lib/tz/resolver";
type SupportedLocale = "de" | "en";
function round(value: number, digits = 1): number {
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
}
function normalizeSummaryText(value: string): string {
return stripChartTokens(value).replace(/\s+/g, " ").trim();
}
function normalizeLocale(value: string | null | undefined): SupportedLocale {
return value === "en" ? "en" : "de";
}
function summarizeSeries(series: Array<{ value: number }>) {
if (series.length === 0) return null;
const first = series[0].value;
const last = series[series.length - 1].value;
// v1.4.33 — fold sum/min/max into a single walk. The previous
// `Math.min(...series.map(...))` / `Math.max(...series.map(...))`
// spread tripped V8's ~125 000-arg ceiling on the bound /api/analytics
// path; see `.planning/round-v1433-analytics-500-report.md` §"Carry-
// over". These helpers are fed bounded windows today so the crash
// never reached them, but the spread allocates a transient args array
// on every call — the fold is both stack-safe and cheaper.
let sum = 0;
let minVal = series[0].value;
let maxVal = series[0].value;
for (const entry of series) {
sum += entry.value;
if (entry.value < minVal) minVal = entry.value;
if (entry.value > maxVal) maxVal = entry.value;
}
return {
points: series.length,
start: round(first, 2),
end: round(last, 2),
delta: round(last - first, 2),
mean: round(sum / series.length, 2),
min: round(minVal, 2),
max: round(maxVal, 2),
};
}
/**
* Cap on the embedded correlation / paired-daily arrays. The Pearson
* coefficients stay computed over the full overlap; only the row-by-row
* lists the prompt carries are trimmed to their most recent entries.
*/
const CORRELATION_PAIR_CAP = 30;
/**
* Pair two daily-bucket series on `dayOffset`. The synthesised `date`
@@ -134,13 +106,18 @@ export async function generateBloodPressureStatusForUser(
const cacheAction = `insights.blood-pressure-status.${locale}`;
const todayKey = toBerlinDayKey(new Date());
const provider = await resolveProvider(userId);
if (provider.type === "none") {
const cached = await readFreshStatusText({
userId,
cacheAction,
todayKey,
force,
});
if (cached) {
return {
hasProvider: false,
text: getNoKeyBloodPressureStatusText(locale),
hasProvider: true,
text: cached.text,
cached: true,
updatedAt: null,
updatedAt: cached.updatedAt,
};
}
@@ -151,35 +128,6 @@ export async function generateBloodPressureStatusForUser(
},
});
const latestCache = await prisma.auditLog.findFirst({
where: { userId, action: cacheAction },
orderBy: { createdAt: "desc" },
select: { createdAt: true, details: true },
});
if (!force && latestCache?.details) {
try {
const parsed = JSON.parse(latestCache.details) as {
dateKey?: string;
text?: string;
};
if (
parsed.dateKey === todayKey &&
typeof parsed.text === "string" &&
parsed.text.trim().length > 0
) {
return {
hasProvider: true,
text: parsed.text,
cached: true,
updatedAt: latestCache.createdAt.toISOString(),
};
}
} catch {
// ignore invalid cache payload
}
}
// v1.4.28 FB-D2 — cap the snapshot input. The downstream
// `applyPayloadBudget` trims further; this `take` keeps the read
// bounded for Apple-Health-rich accounts. BP captures three types
@@ -397,6 +345,27 @@ export async function generateBloodPressureStatusForUser(
);
const moodMean = moodSummary?.mean ?? null;
// Compact graded series for the prompt. The `*Series.daily` buckets
// above stay for the correlation pairing + in-target gate; only the
// graded shape is embedded so the full daily arrays never ship.
const gradedFromDaily = (buckets: DailyBucket[]) =>
buildGradedSeriesFromPoints(
buckets.map((b) => ({
measuredAt: new Date(dayOffsetToBerlinDayKey(now, b.dayOffset)),
value: b.value,
})),
now,
);
// The two BP channels source their recent / weekly slices from a
// bounded raw read and their monthly / yearly tail from the MONTH /
// YEAR rollup tier (full-history in-memory fallback on a cold-tier
// coverage miss). Mood has no rollup tier, so it stays an in-memory fold.
const [sysGraded, diaGraded] = await Promise.all([
buildGradedSeriesWithRollups(userId, "BLOOD_PRESSURE_SYS", now),
buildGradedSeriesWithRollups(userId, "BLOOD_PRESSURE_DIA", now),
]);
const moodGraded = gradedFromDaily(moodSeries.daily);
const continuityVsSystolicPairs: PairedPoint[] = continuityVsSystolicSeries
.map((entry) => {
if (entry.continuityPct == null) return null;
@@ -446,13 +415,13 @@ export async function generateBloodPressureStatusForUser(
summary: summarizeSeries(
sysSeries.daily.map((bucket) => ({ value: bucket.value })),
),
series: sysSeries,
series: sysGraded,
},
diastolic: {
summary: summarizeSeries(
diaSeries.daily.map((bucket) => ({ value: bucket.value })),
),
series: diaSeries,
series: diaGraded,
},
paired: {
summary: summarizeSeries(
@@ -460,7 +429,7 @@ export async function generateBloodPressureStatusForUser(
value: (entry.sys + entry.dia) / 2,
})),
),
series: pairedBloodPressure,
series: pairedBloodPressure.slice(-CORRELATION_PAIR_CAP),
},
targets: bpTargets
? {
@@ -472,7 +441,7 @@ export async function generateBloodPressureStatusForUser(
},
weightVsSystolic: {
correlation: weightVsSystolicCorrelation,
pairs: weightVsSystolicPairs.map((entry) => ({
pairs: weightVsSystolicPairs.slice(-CORRELATION_PAIR_CAP).map((entry) => ({
day: entry.dayKey,
weight: round(entry.a, 2),
systolic: round(entry.b, 2),
@@ -482,7 +451,7 @@ export async function generateBloodPressureStatusForUser(
expectedIntakesPerDay: expectedBpIntakesPerDay,
medicationCount: bpMedications.length,
correlation: continuityVsSystolicCorrelation,
series: continuityVsSystolicSeries,
series: continuityVsSystolicSeries.slice(-CORRELATION_PAIR_CAP),
},
bpMedications: medicationCompliance,
moodContext:
@@ -491,7 +460,7 @@ export async function generateBloodPressureStatusForUser(
points: moodSeries.daily.length,
mean: moodMean,
latest: moodSeries.daily[0]?.value ?? null,
series: moodSeries,
series: moodGraded,
moodVsSystolicCorrelation: (() => {
const moodVsSysPairs = pairDailyBuckets(
moodSeries.daily,
@@ -504,11 +473,17 @@ export async function generateBloodPressureStatusForUser(
: null,
};
const shed = degradeStatusSnapshotToBudget(
snapshot as unknown as Record<string, unknown>,
);
const snapshotJson = JSON.stringify(snapshot, null, 2);
annotate({
action: { name: cacheAction },
meta: { payload_size_bytes: snapshotJson.length },
meta: {
payload_size_bytes: snapshotJson.length,
...(shed.length > 0 ? { snapshot_shed: shed } : {}),
},
});
const previousContext = await getPreviousInsightContext(
@@ -522,86 +497,59 @@ export async function generateBloodPressureStatusForUser(
locale,
);
// v1.4.28 FB-D2 — 20 s timeout race; fall back to the no-key text
// on stall so the InsightStatusCard renders deterministically.
const raced = await withTimeout(
() =>
provider.generateCompletion({
systemPrompt: getBloodPressureSystemPrompt(locale),
userPrompt: getBloodPressureUserPrompt(
snapshotJson,
todayKey,
locale,
previousContextBlock,
),
temperature: 0.3,
maxTokens: 1000,
}),
STATUS_PROVIDER_TIMEOUT_MS,
null,
);
if (raced.timedOut || raced.value === null) {
// v1.4.37 — persist a sentinel row keyed to today so the next
// mount short-circuits at the cache lookup above instead of
// re-racing the same 20 s provider call on every cold visit.
// See `persistTimeoutStubAndReturn` for the full rationale.
return persistTimeoutStubAndReturn({
userId,
cacheAction,
const outcome = await runStatusCompletion({
userId,
cacheAction,
systemPrompt: getBloodPressureSystemPrompt(locale),
userPrompt: getBloodPressureUserPrompt(
snapshotJson,
todayKey,
locale,
providerType: provider.type,
previousContextBlock,
),
temperature: 0.3,
maxTokens: 1000,
});
if (outcome.kind === "none") {
return {
hasProvider: false,
text: getNoKeyBloodPressureStatusText(locale),
cached: true,
updatedAt: null,
};
}
if (outcome.kind === "timeout" || outcome.kind === "error") {
return returnTimeoutFallback({
cacheAction,
reason: outcome.kind,
stubText: getNoKeyBloodPressureStatusText(locale),
});
}
const result = raced.value;
const content = result.content;
if (typeof content !== "string" || content.trim().length === 0) {
throw new Error("AI returned empty content for blood-pressure-status");
}
let summary = "";
try {
const parsed = JSON.parse(content) as { summary?: string };
if (typeof parsed.summary === "string") {
summary = parsed.summary;
} else {
summary = content;
}
} catch {
summary = content;
}
summary = normalizeSummaryText(summary);
const summary = normalizeSummaryText(parseSummaryFromContent(outcome.content));
if (!summary) {
throw new Error(
"Blood-pressure-status summary was empty after normalization",
);
}
const created = await prisma.auditLog.create({
data: {
userId,
action: cacheAction,
details: JSON.stringify({
dateKey: todayKey,
locale,
text: summary,
providerType: provider.type,
model: result.model ?? "unknown",
tokensUsed: result.tokensUsed ?? null,
}),
},
select: { createdAt: true },
const updatedAt = await persistStatusInsight({
userId,
cacheAction,
todayKey,
locale,
text: summary,
providerType: outcome.providerType,
model: outcome.model,
tokensUsed: outcome.tokensUsed,
});
return {
hasProvider: true,
text: summary,
cached: false,
updatedAt: created.createdAt.toISOString(),
updatedAt,
};
}
+90 -169
View File
@@ -1,5 +1,4 @@
import { prisma } from "@/lib/db";
import { resolveProvider } from "@/lib/ai/provider";
import { getBmiSystemPrompt, getBmiUserPrompt } from "@/lib/ai/prompts/bmi";
import { classifyBMI } from "@/lib/analytics/classifications";
import { getNoKeyBmiStatusText } from "@/lib/insights/no-key-fallbacks";
@@ -8,61 +7,26 @@ import {
getPreviousInsightContext,
} from "@/lib/insights/memory";
import { applyPayloadBudget } from "@/lib/insights/bucket-series";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import {
withTimeout,
STATUS_PROVIDER_TIMEOUT_MS,
} from "@/lib/insights/with-timeout";
import { persistTimeoutStubAndReturn } from "@/lib/insights/persist-timeout-stub";
buildGradedSeriesWithRollups,
scaleGradedSeries,
degradeStatusSnapshotToBudget,
} from "@/lib/insights/graded-series";
import {
type SupportedLocale,
normalizeLocale,
normalizeSummaryText,
parseSummaryFromContent,
persistStatusInsight,
round,
summarizeSeries,
} from "@/lib/insights/status-shared";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { readFreshStatusText } from "@/lib/insights/status-cache";
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
import { annotate } from "@/lib/logging/context";
import { toBerlinDayKey } from "@/lib/tz/resolver";
type SupportedLocale = "de" | "en";
function round(value: number, digits = 1): number {
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
}
function normalizeSummaryText(value: string): string {
return stripChartTokens(value).replace(/\s+/g, " ").trim();
}
function normalizeLocale(value: string | null | undefined): SupportedLocale {
return value === "en" ? "en" : "de";
}
function summarizeSeries(series: Array<{ value: number }>) {
if (series.length === 0) return null;
const first = series[0].value;
const last = series[series.length - 1].value;
// v1.4.33 — fold sum/min/max into a single walk. The previous
// `Math.min(...series.map(...))` / `Math.max(...series.map(...))`
// spread tripped V8's ~125 000-arg ceiling on the bound /api/analytics
// path and is the same anti-pattern fixed in `summarize()` (see
// `.planning/round-v1433-analytics-500-report.md` §"Carry-over").
// These helpers feed off bounded windows today so they did not crash,
// but the spread allocates a transient args array on every call;
// folding once is both stack-safe and cheaper.
let sum = 0;
let minVal = series[0].value;
let maxVal = series[0].value;
for (const entry of series) {
sum += entry.value;
if (entry.value < minVal) minVal = entry.value;
if (entry.value > maxVal) maxVal = entry.value;
}
return {
points: series.length,
start: round(first, 2),
end: round(last, 2),
delta: round(last - first, 2),
mean: round(sum / series.length, 2),
min: round(minVal, 2),
max: round(maxVal, 2),
};
}
export async function generateBmiStatusForUser(
userId: string,
options?: {
@@ -80,13 +44,18 @@ export async function generateBmiStatusForUser(
const cacheAction = `insights.bmi-status.${locale}`;
const todayKey = toBerlinDayKey(new Date());
const provider = await resolveProvider(userId);
if (provider.type === "none") {
const cached = await readFreshStatusText({
userId,
cacheAction,
todayKey,
force,
});
if (cached) {
return {
hasProvider: false,
text: getNoKeyBmiStatusText(locale),
hasProvider: true,
text: cached.text,
cached: true,
updatedAt: null,
updatedAt: cached.updatedAt,
};
}
@@ -97,35 +66,6 @@ export async function generateBmiStatusForUser(
},
});
const latestCache = await prisma.auditLog.findFirst({
where: { userId, action: cacheAction },
orderBy: { createdAt: "desc" },
select: { createdAt: true, details: true },
});
if (!force && latestCache?.details) {
try {
const parsed = JSON.parse(latestCache.details) as {
dateKey?: string;
text?: string;
};
if (
parsed.dateKey === todayKey &&
typeof parsed.text === "string" &&
parsed.text.trim().length > 0
) {
return {
hasProvider: true,
text: parsed.text,
cached: true,
updatedAt: latestCache.createdAt.toISOString(),
};
}
} catch {
// ignore invalid cache payload
}
}
if (!user?.heightCm || user.heightCm <= 0) {
return {
hasProvider: true,
@@ -134,7 +74,7 @@ export async function generateBmiStatusForUser(
? "Für die BMI-Einschätzung fehlen aktuell Größenangaben im Profil."
: "BMI assessment currently requires height data in the profile.",
cached: true,
updatedAt: latestCache?.createdAt.toISOString() ?? null,
updatedAt: null,
};
}
@@ -157,26 +97,28 @@ export async function generateBmiStatusForUser(
.then((rows) => rows.reverse());
const now = new Date();
const weightSeries = applyPayloadBudget(
measurements.map((measurement) => ({
measuredAt: measurement.measuredAt,
value: measurement.value,
})),
{ now },
);
const heightFactor = (user.heightCm / 100) ** 2;
const bmiPoints = measurements.map((measurement) => ({
measuredAt: measurement.measuredAt,
value: round(measurement.value / heightFactor, 2),
}));
// `applyPayloadBudget` daily buckets drive the latest/previous focus;
// the compact graded series is what reaches the prompt.
const weightSeries = applyPayloadBudget(bmiPoints, { now });
// BMI has no rollup tier of its own — it is weight ÷ height², a linear
// transform by the per-user height factor. Read the WEIGHT tier (recent
// / weekly from a bounded raw read, monthly / yearly from MONTH / YEAR)
// and scale by 1 / heightFactor; the scaled series is exact.
const weightGraded = await buildGradedSeriesWithRollups(
userId,
"WEIGHT",
now,
);
const bmiGraded = scaleGradedSeries(weightGraded, 1 / heightFactor, 2);
const bmiSeries = {
daily: weightSeries.daily.map((bucket) => ({
dayOffset: bucket.dayOffset,
value: round(bucket.value / heightFactor, 2),
n: bucket.n,
})),
monthly: weightSeries.monthly.map((bucket) => ({
monthOffset: bucket.monthOffset,
value: round(bucket.value / heightFactor, 2),
n: bucket.n,
})),
daily: weightSeries.daily,
monthly: weightSeries.monthly,
};
// daily[0] = newest bucket (lowest dayOffset).
@@ -216,7 +158,7 @@ export async function generateBmiStatusForUser(
summary: summarizeSeries(
bmiSeries.daily.map((bucket) => ({ value: bucket.value })),
),
series: bmiSeries,
series: bmiGraded,
latestDayFocus: latestBmi
? {
dayOffset: latestBmi.dayOffset,
@@ -235,11 +177,17 @@ export async function generateBmiStatusForUser(
},
};
const shed = degradeStatusSnapshotToBudget(
snapshot as unknown as Record<string, unknown>,
);
const snapshotJson = JSON.stringify(snapshot, null, 2);
annotate({
action: { name: cacheAction },
meta: { payload_size_bytes: snapshotJson.length },
meta: {
payload_size_bytes: snapshotJson.length,
...(shed.length > 0 ? { snapshot_shed: shed } : {}),
},
});
const previousContext = await getPreviousInsightContext(
@@ -253,84 +201,57 @@ export async function generateBmiStatusForUser(
locale,
);
// v1.4.28 FB-D2 — 20 s timeout race; fall back to the no-key text
// on stall so the InsightStatusCard renders deterministically.
const raced = await withTimeout(
() =>
provider.generateCompletion({
systemPrompt: getBmiSystemPrompt(locale),
userPrompt: getBmiUserPrompt(
snapshotJson,
todayKey,
locale,
previousContextBlock,
),
temperature: 0.3,
maxTokens: 1000,
}),
STATUS_PROVIDER_TIMEOUT_MS,
null,
);
if (raced.timedOut || raced.value === null) {
// v1.4.37 — persist a sentinel row keyed to today so the next
// mount short-circuits at the cache lookup above instead of
// re-racing the same 20 s provider call on every cold visit.
// See `persistTimeoutStubAndReturn` for the full rationale.
return persistTimeoutStubAndReturn({
userId,
cacheAction,
const outcome = await runStatusCompletion({
userId,
cacheAction,
systemPrompt: getBmiSystemPrompt(locale),
userPrompt: getBmiUserPrompt(
snapshotJson,
todayKey,
locale,
providerType: provider.type,
previousContextBlock,
),
temperature: 0.3,
maxTokens: 1000,
});
if (outcome.kind === "none") {
return {
hasProvider: false,
text: getNoKeyBmiStatusText(locale),
cached: true,
updatedAt: null,
};
}
if (outcome.kind === "timeout" || outcome.kind === "error") {
return returnTimeoutFallback({
cacheAction,
reason: outcome.kind,
stubText: getNoKeyBmiStatusText(locale),
});
}
const result = raced.value;
const content = result.content;
if (typeof content !== "string" || content.trim().length === 0) {
throw new Error("AI returned empty content for bmi-status");
}
let summary = "";
try {
const parsed = JSON.parse(content) as { summary?: string };
if (typeof parsed.summary === "string") {
summary = parsed.summary;
} else {
summary = content;
}
} catch {
summary = content;
}
summary = normalizeSummaryText(summary);
const summary = normalizeSummaryText(parseSummaryFromContent(outcome.content));
if (!summary) {
throw new Error("Bmi-status summary was empty after normalization");
}
const created = await prisma.auditLog.create({
data: {
userId,
action: cacheAction,
details: JSON.stringify({
dateKey: todayKey,
locale,
text: summary,
providerType: provider.type,
model: result.model ?? "unknown",
tokensUsed: result.tokensUsed ?? null,
}),
},
select: { createdAt: true },
const updatedAt = await persistStatusInsight({
userId,
cacheAction,
todayKey,
locale,
text: summary,
providerType: outcome.providerType,
model: outcome.model,
tokensUsed: outcome.tokensUsed,
});
return {
hasProvider: true,
text: summary,
cached: false,
updatedAt: created.createdAt.toISOString(),
updatedAt,
};
}
+1 -1
View File
@@ -16,7 +16,7 @@ import type { CoachScopeSource } from "@/lib/ai/coach/types";
*
* Until v1.4.27 the Coach drawer was mounted inline on
* `/insights/page.tsx` body, which meant navigating to a routed
* sub-page (`/insights/blutdruck`, etc.) unmounted the drawer and
* sub-page (`/insights/blood-pressure`, etc.) unmounted the drawer and
* removed every entry point to the Coach. The MA3 audit + Decision F
* promoted the drawer up to `/insights/layout.tsx` so every routed
* Insights surface can launch it.
@@ -157,6 +157,82 @@ export async function evictPerStatusInsightCache(
});
}
/**
* The seven per-metric assessment scopes. Each generator persists its
* cached text under `insights.<scope>-status.<locale>`; the warm-pass in
* the nightly cron re-fills these after `evictPerStatusInsightCache`
* clears them, and the targeted invalidator below drops only the scopes
* a fresh measurement of a given type actually dirties.
*/
export const PER_STATUS_SCOPES = [
"blood-pressure",
"pulse",
"weight",
"bmi",
"mood",
"medication-compliance",
"general",
] as const;
export type PerStatusScope = (typeof PER_STATUS_SCOPES)[number];
/**
* Map a measurement type to the assessment scopes a fresh reading of it
* dirties. `general` is the catch-all overview, so every measurement
* type touches it. BMI rides on WEIGHT (it is weight ÷ height²), so a
* new weight reading invalidates both the weight and the BMI card.
*/
function statusScopesForMeasurementType(type: MeasurementType): PerStatusScope[] {
switch (type) {
case "WEIGHT":
return ["weight", "bmi", "general"];
case "BLOOD_PRESSURE_SYS":
case "BLOOD_PRESSURE_DIA":
return ["blood-pressure", "general"];
case "PULSE":
case "RESTING_HEART_RATE":
return ["pulse", "general"];
default:
// Every other tracked metric (body composition, sleep, steps,
// glucose, …) still feeds the general overview assessment.
return ["general"];
}
}
/**
* Drop the cached per-metric assessment rows that a batch of fresh
* measurements dirties, so the next mount (or the next nightly warm
* pass) regenerates them against the new data instead of serving the
* pre-measurement text for the rest of the day.
*
* Fire-and-forget from the measurement ingest path — idempotent (a
* redundant delete costs nothing) and never a blocker on the user's
* write. Deletes every locale variant of each affected scope in one
* sweep; the `-status.` substring guard keeps it from touching the
* comprehensive cache or any unrelated `insights.*` audit row.
*/
export async function invalidateStatusInsightsForTypes(
userId: string,
types: Iterable<MeasurementType>,
): Promise<void> {
const scopes = new Set<PerStatusScope>();
for (const type of types) {
for (const scope of statusScopesForMeasurementType(type)) {
scopes.add(scope);
}
}
if (scopes.size === 0) return;
await prisma.auditLog.deleteMany({
where: {
userId,
OR: Array.from(scopes, (scope) => ({
action: { startsWith: `insights.${scope}-status.` },
})),
},
});
}
interface GenerateOptions {
/** Resolved UI locale for the prompt + cache row. */
locale: "de" | "en";
+101 -159
View File
@@ -1,5 +1,4 @@
import { prisma } from "@/lib/db";
import { resolveProvider } from "@/lib/ai/provider";
import {
getGeneralStatusSystemPrompt,
getGeneralStatusUserPrompt,
@@ -13,63 +12,35 @@ import {
getPreviousInsightContext,
} from "@/lib/insights/memory";
import { applyPayloadBudget } from "@/lib/insights/bucket-series";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import {
withTimeout,
STATUS_PROVIDER_TIMEOUT_MS,
} from "@/lib/insights/with-timeout";
import { persistTimeoutStubAndReturn } from "@/lib/insights/persist-timeout-stub";
buildGradedSeriesFromPoints,
degradeStatusSnapshotToBudget,
} from "@/lib/insights/graded-series";
import {
normalizeLocale,
normalizeSummaryText,
parseSummaryFromContent,
persistStatusInsight,
round,
summarizeSeries,
} from "@/lib/insights/status-shared";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { readFreshStatusText } from "@/lib/insights/status-cache";
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
import { annotate } from "@/lib/logging/context";
import { toBerlinDayKey } from "@/lib/tz/resolver";
type SupportedLocale = "de" | "en";
// Derived from canonical enum so a new measurement type is auto-included
// in the AI general-status fetch (V3 audit: enum drift cousins).
const MEASUREMENT_TYPES = measurementTypeEnum.options;
function round(value: number, digits = 1): number {
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
}
function normalizeSummaryText(value: string): string {
return stripChartTokens(value).replace(/\s+/g, " ").trim();
}
function summarizeSeries(series: Array<{ value: number }>) {
if (series.length === 0) return null;
const first = series[0].value;
const last = series[series.length - 1].value;
// v1.4.33 — fold sum/min/max into a single walk. The previous
// `Math.min(...series.map(...))` / `Math.max(...series.map(...))`
// spread tripped V8's ~125 000-arg ceiling on the bound /api/analytics
// path; see `.planning/round-v1433-analytics-500-report.md` §"Carry-
// over". These helpers are fed bounded windows today so the crash
// never reached them, but the spread allocates a transient args array
// on every call — the fold is both stack-safe and cheaper.
let sum = 0;
let minVal = series[0].value;
let maxVal = series[0].value;
for (const entry of series) {
sum += entry.value;
if (entry.value < minVal) minVal = entry.value;
if (entry.value > maxVal) maxVal = entry.value;
}
return {
points: series.length,
start: round(first, 2),
end: round(last, 2),
delta: round(last - first, 2),
mean: round(sum / series.length, 2),
min: round(minVal, 2),
max: round(maxVal, 2),
};
}
function normalizeLocale(value: string | null | undefined): SupportedLocale {
return value === "en" ? "en" : "de";
}
// The general overview folds its per-type graded series in memory from a
// single bounded read (take 5000, desc) rather than per-type rollup-tier
// reads: a many-metric account would otherwise fan out ~15 metrics × 3
// rollup round-trips per render. `degradeStatusSnapshotToBudget` already
// caps the multi-metric snapshot, so the bounded in-memory fold is the
// deliberate design here; the rollup tier is wired into the focused
// single-metric cards (weight / pulse / bmi / blood-pressure) instead.
export async function generateGeneralStatusForUser(
userId: string,
@@ -88,13 +59,18 @@ export async function generateGeneralStatusForUser(
const cacheAction = `insights.general-status.${locale}`;
const todayKey = toBerlinDayKey(new Date());
const provider = await resolveProvider(userId);
if (provider.type === "none") {
const cached = await readFreshStatusText({
userId,
cacheAction,
todayKey,
force,
});
if (cached) {
return {
hasProvider: false,
text: getNoKeyGeneralStatusText(locale),
hasProvider: true,
text: cached.text,
cached: true,
updatedAt: null,
updatedAt: cached.updatedAt,
};
}
@@ -105,35 +81,6 @@ export async function generateGeneralStatusForUser(
},
});
const latestCache = await prisma.auditLog.findFirst({
where: { userId, action: cacheAction },
orderBy: { createdAt: "desc" },
select: { createdAt: true, details: true },
});
if (!force && latestCache?.details) {
try {
const parsed = JSON.parse(latestCache.details) as {
dateKey?: string;
text?: string;
};
if (
parsed.dateKey === todayKey &&
typeof parsed.text === "string" &&
parsed.text.trim().length > 0
) {
return {
hasProvider: true,
text: parsed.text,
cached: true,
updatedAt: latestCache.createdAt.toISOString(),
};
}
} catch {
// ignore invalid cache payload
}
}
// v1.4.28 FB-D2 — cap the snapshot input. General-status pulls
// every supported measurement type; without a cap the read scales
// linearly with account density.
@@ -157,25 +104,39 @@ export async function generateGeneralStatusForUser(
const now = new Date();
// `dailyByType` keeps the `applyPayloadBudget` daily buckets for the
// downstream BP in-target pairing; the prompt embeds the compact
// graded series instead of the full daily array. Skip types with no
// data so a many-metric account can't balloon the snapshot with empty
// series objects.
const dailyByType = new Map<
(typeof MEASUREMENT_TYPES)[number],
ReturnType<typeof applyPayloadBudget>
>();
const measurementSeries = Object.fromEntries(
MEASUREMENT_TYPES.map((type) => {
MEASUREMENT_TYPES.flatMap((type) => {
const records = measurements
.filter((measurement) => measurement.type === type)
.map((measurement) => ({
measuredAt: measurement.measuredAt,
value: measurement.value,
}));
if (records.length === 0) return [];
const series = applyPayloadBudget(records, { now });
dailyByType.set(type, series);
const graded = buildGradedSeriesFromPoints(records, now);
return [
type,
{
summary: summarizeSeries(
series.daily.map((bucket) => ({ value: bucket.value })),
),
series,
},
[
type,
{
summary: summarizeSeries(
series.daily.map((bucket) => ({ value: bucket.value })),
),
series: graded,
},
] as const,
];
}),
);
@@ -222,6 +183,7 @@ export async function generateGeneralStatusForUser(
value: value.total > 0 ? round((value.taken / value.total) * 100, 1) : 0,
}));
const adherenceSeries = applyPayloadBudget(adherenceRecords, { now });
const adherenceGraded = buildGradedSeriesFromPoints(adherenceRecords, now);
// Fetch mood context (optional — for enrichment only). v1.4.28
// FB-D2 — cap at 90 entries.
@@ -239,6 +201,7 @@ export async function generateGeneralStatusForUser(
value: entry.score,
}));
const moodSeries = applyPayloadBudget(moodRecords, { now });
const moodGraded = buildGradedSeriesFromPoints(moodRecords, now);
const moodSummary = summarizeSeries(
moodSeries.daily.map((bucket) => ({ value: bucket.value })),
);
@@ -247,9 +210,9 @@ export async function generateGeneralStatusForUser(
const bpTargets = getBpTargets(user?.dateOfBirth ?? null);
let bpInTargetLast30Days: number | null = null;
if (bpTargets) {
const sysDaily = measurementSeries.BLOOD_PRESSURE_SYS?.series.daily ?? [];
const sysDaily = dailyByType.get("BLOOD_PRESSURE_SYS")?.daily ?? [];
const diaMap = new Map(
(measurementSeries.BLOOD_PRESSURE_DIA?.series.daily ?? []).map(
(dailyByType.get("BLOOD_PRESSURE_DIA")?.daily ?? []).map(
(bucket) => [bucket.dayOffset, bucket.value] as const,
),
);
@@ -312,7 +275,7 @@ export async function generateGeneralStatusForUser(
summary: summarizeSeries(
adherenceSeries.daily.map((bucket) => ({ value: bucket.value })),
),
series: adherenceSeries,
series: adherenceGraded,
},
bloodPressureTargets: bpTargets
? {
@@ -327,16 +290,22 @@ export async function generateGeneralStatusForUser(
points: moodSeries.daily.length,
mean: moodMean,
latest: moodSeries.daily[0]?.value ?? null,
series: moodSeries,
series: moodGraded,
}
: null,
};
const shed = degradeStatusSnapshotToBudget(
snapshot as unknown as Record<string, unknown>,
);
const snapshotJson = JSON.stringify(snapshot, null, 2);
annotate({
action: { name: cacheAction },
meta: { payload_size_bytes: snapshotJson.length },
meta: {
payload_size_bytes: snapshotJson.length,
...(shed.length > 0 ? { snapshot_shed: shed } : {}),
},
});
// v1.4: pull the previous cached general-status into the prompt so
@@ -353,84 +322,57 @@ export async function generateGeneralStatusForUser(
locale,
);
// v1.4.28 FB-D2 — 20 s timeout race; fall back to the no-key text
// on stall so the InsightStatusCard renders deterministically.
const raced = await withTimeout(
() =>
provider.generateCompletion({
systemPrompt: getGeneralStatusSystemPrompt(locale),
userPrompt: getGeneralStatusUserPrompt(
snapshotJson,
todayKey,
locale,
previousContextBlock,
),
temperature: 0.3,
maxTokens: 1000,
}),
STATUS_PROVIDER_TIMEOUT_MS,
null,
);
if (raced.timedOut || raced.value === null) {
// v1.4.37 — persist a sentinel row keyed to today so the next
// mount short-circuits at the cache lookup above instead of
// re-racing the same 20 s provider call on every cold visit.
// See `persistTimeoutStubAndReturn` for the full rationale.
return persistTimeoutStubAndReturn({
userId,
cacheAction,
const outcome = await runStatusCompletion({
userId,
cacheAction,
systemPrompt: getGeneralStatusSystemPrompt(locale),
userPrompt: getGeneralStatusUserPrompt(
snapshotJson,
todayKey,
locale,
providerType: provider.type,
previousContextBlock,
),
temperature: 0.3,
maxTokens: 1000,
});
if (outcome.kind === "none") {
return {
hasProvider: false,
text: getNoKeyGeneralStatusText(locale),
cached: true,
updatedAt: null,
};
}
if (outcome.kind === "timeout" || outcome.kind === "error") {
return returnTimeoutFallback({
cacheAction,
reason: outcome.kind,
stubText: getNoKeyGeneralStatusText(locale),
});
}
const result = raced.value;
const content = result.content;
if (typeof content !== "string" || content.trim().length === 0) {
throw new Error("AI returned empty content for general-status");
}
let summary = "";
try {
const parsed = JSON.parse(content) as { summary?: string };
if (typeof parsed.summary === "string") {
summary = parsed.summary;
} else {
summary = content;
}
} catch {
summary = content;
}
summary = normalizeSummaryText(summary);
const summary = normalizeSummaryText(parseSummaryFromContent(outcome.content));
if (!summary) {
throw new Error("General-status summary was empty after normalization");
}
const created = await prisma.auditLog.create({
data: {
userId,
action: cacheAction,
details: JSON.stringify({
dateKey: todayKey,
locale,
text: summary,
providerType: provider.type,
model: result.model ?? "unknown",
tokensUsed: result.tokensUsed ?? null,
}),
},
select: { createdAt: true },
const updatedAt = await persistStatusInsight({
userId,
cacheAction,
todayKey,
locale,
text: summary,
providerType: outcome.providerType,
model: outcome.model,
tokensUsed: outcome.tokensUsed,
});
return {
hasProvider: true,
text: summary,
cached: false,
updatedAt: created.createdAt.toISOString(),
updatedAt,
};
}
+544
View File
@@ -0,0 +1,544 @@
/**
* Shared graded-series compressor for AI insight snapshots.
*
* Background — see `.planning/research/ai-payload-compression-audit.md`.
* The seven per-card status generators embedded the FULL daily array
* per metric (`bucketSeries`, up to ~360 daily buckets + ~24 monthly)
* plus correlation pair arrays, and originally never touched the WEEK /
* MONTH / YEAR rollup tier. A daily-weigher's `weight-status` snapshot ran
* ~23 KB for weight alone; `general-status` across many Apple-Health
* types could blow past 100 K tokens. The Coach is the one path that
* already grades its timeline (recent days verbatim → weekly means →
* bounded window).
*
* As of v1.8.0 the focused single-metric cards (weight / pulse / bmi /
* blood-pressure) source their monthly / yearly slices from the MONTH /
* YEAR rollup tier via `buildGradedSeriesWithRollups`, so the coarse
* tiers finally earn their write amplification. The multi-metric
* `general-status` and the tier-less mood / adherence series stay on the
* bounded in-memory fold (`buildGradedSeriesFromPoints`).
*
* This module is the single graded shape every status path uses:
*
* recent last ~21 days daily min/max/mean/n (~14-21 rows)
* weekly the ~10 weeks before ISO wk min/max/mean/n (~8-10 rows)
* monthly the ~12 months before month min/max/mean (~9-12 rows)
* yearly everything older year mean/slope (~1-3 rows)
*
* ~30-45 rows per metric regardless of history depth, never an
* individual reading beyond `recent`.
*
* Two entry points:
* - `buildGradedSeriesFromPoints(points, now)` — pure, in-memory. The
* recent / weekly / monthly slices fold raw points; the yearly slice
* carries a least-squares slope. Used where the caller already holds
* the rows (and by the tests).
* - `buildGradedSeriesWithRollups(userId, type, now)` — sources the
* monthly / yearly slices from the pre-aggregated MONTH / YEAR
* rollup tier (`readBestGranularityRollups`) and the recent / weekly
* slices from a bounded raw read, so the coarse tiers finally earn
* their write amplification.
*
* The recent / weekly bucketing mirrors the Coach
* (`coach/snapshot.ts:bucketWeekly` + `buildDailyValueRows`). Those
* helpers are intentionally duplicated rather than lifted out of the
* Coach: the Coach contract must not move under it, and the shapes here
* carry min/max (the Coach only needs the mean). The two are small and
* independently testable.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import {
readBestGranularityRollups,
type RollupBucketRow,
} from "@/lib/rollups/measurement-read-wmy";
import { toBerlinYmd } from "@/lib/tz/resolver";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
/** Days kept verbatim at daily granularity. */
const RECENT_DAYS = 21;
/** ISO weeks folded after the recent window. */
const WEEKLY_WEEKS = 10;
/** Calendar months folded after the weekly window. */
const MONTHLY_MONTHS = 12;
const RECENT_WINDOW_MS = RECENT_DAYS * MS_PER_DAY;
const WEEKLY_WINDOW_MS = (RECENT_DAYS + WEEKLY_WEEKS * 7) * MS_PER_DAY;
// Approximate the monthly horizon in days; the precise calendar-month
// boundary is handled by the YMD key, this only separates monthly from
// yearly buckets.
const MONTHLY_WINDOW_MS =
WEEKLY_WINDOW_MS + MONTHLY_MONTHS * 30 * MS_PER_DAY;
export interface RecentDayBucket {
/** Berlin YYYY-MM-DD. */
date: string;
min: number;
max: number;
mean: number;
n: number;
}
export interface WeeklyBucket {
/** ISO week key like 2026-W19 (Berlin-anchored). */
weekISO: string;
min: number;
max: number;
mean: number;
n: number;
}
export interface MonthlyBucket {
/** Berlin YYYY-MM. */
month: string;
min: number;
max: number;
mean: number;
n: number;
}
export interface YearlyBucket {
/** Berlin YYYY. */
year: string;
min: number;
max: number;
mean: number;
n: number;
/** Least-squares slope per reading order (null when < 2 points). */
slope: number | null;
}
export interface GradedSeries {
recent: RecentDayBucket[];
weekly: WeeklyBucket[];
monthly: MonthlyBucket[];
yearly: YearlyBucket[];
}
interface Point {
measuredAt: Date;
value: number;
}
function round(value: number, digits = 2): number {
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
}
function berlinDayKey(date: Date): string {
const { year, month, day } = toBerlinYmd(date);
return `${year}-${month}-${day}`;
}
function berlinMonthKey(date: Date): string {
const { year, month } = toBerlinYmd(date);
return `${year}-${month}`;
}
function berlinYearKey(date: Date): string {
return toBerlinYmd(date).year;
}
/**
* ISO week key (Berlin-anchored). Mirrors the Coach's `isoWeekKey` but
* fixed to the user's Berlin display day so the week label agrees with
* the rest of the snapshot.
*/
function berlinIsoWeekKey(date: Date): string {
const { year, month, day } = toBerlinYmd(date);
const localMidnight = new Date(
Date.UTC(Number(year), Number(month) - 1, Number(day)),
);
const dayNum = localMidnight.getUTCDay() || 7;
localMidnight.setUTCDate(localMidnight.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(localMidnight.getUTCFullYear(), 0, 1));
const weekNo = Math.ceil(
((localMidnight.getTime() - yearStart.getTime()) / MS_PER_DAY + 1) / 7,
);
return `${localMidnight.getUTCFullYear()}-W${String(weekNo).padStart(2, "0")}`;
}
interface Agg {
sum: number;
min: number;
max: number;
n: number;
}
function foldInto(map: Map<string, Agg>, key: string, value: number): void {
const existing = map.get(key);
if (existing) {
existing.sum += value;
existing.n += 1;
if (value < existing.min) existing.min = value;
if (value > existing.max) existing.max = value;
} else {
map.set(key, { sum: value, min: value, max: value, n: 1 });
}
}
/**
* Least-squares slope of `values` against their index (0..n-1). Returns
* null for fewer than two points. The yearly trend signal — direction
* over the order the readings landed.
*/
function leastSquaresSlope(values: number[]): number | null {
const n = values.length;
if (n < 2) return null;
let sumX = 0;
let sumY = 0;
let sumXY = 0;
let sumXX = 0;
for (let i = 0; i < n; i++) {
sumX += i;
sumY += values[i];
sumXY += i * values[i];
sumXX += i * i;
}
const denom = n * sumXX - sumX * sumX;
if (denom === 0) return null;
return round((n * sumXY - sumX * sumY) / denom, 4);
}
/**
* Build the graded series purely in memory. `points` may carry several
* readings per day; same-period readings are folded into one bucket.
*/
export function buildGradedSeriesFromPoints(
points: Point[],
now: Date,
): GradedSeries {
const nowMs = now.getTime();
const recentAgg = new Map<string, Agg>();
const weeklyAgg = new Map<string, Agg>();
const monthlyAgg = new Map<string, Agg>();
// Yearly keeps ordered values for the slope, plus min/max/sum.
const yearlyValues = new Map<string, number[]>();
const yearlyAgg = new Map<string, Agg>();
for (const p of points) {
const ageMs = nowMs - p.measuredAt.getTime();
if (ageMs < 0) continue; // future reading — ignore
if (ageMs < RECENT_WINDOW_MS) {
foldInto(recentAgg, berlinDayKey(p.measuredAt), p.value);
} else if (ageMs < WEEKLY_WINDOW_MS) {
foldInto(weeklyAgg, berlinIsoWeekKey(p.measuredAt), p.value);
} else if (ageMs < MONTHLY_WINDOW_MS) {
foldInto(monthlyAgg, berlinMonthKey(p.measuredAt), p.value);
} else {
const yk = berlinYearKey(p.measuredAt);
foldInto(yearlyAgg, yk, p.value);
const list = yearlyValues.get(yk);
if (list) list.push(p.value);
else yearlyValues.set(yk, [p.value]);
}
}
const recent: RecentDayBucket[] = Array.from(recentAgg.entries())
.map(([date, a]) => ({
date,
min: round(a.min),
max: round(a.max),
mean: round(a.sum / a.n),
n: a.n,
}))
.sort((x, y) => x.date.localeCompare(y.date));
const weekly: WeeklyBucket[] = Array.from(weeklyAgg.entries())
.map(([weekISO, a]) => ({
weekISO,
min: round(a.min),
max: round(a.max),
mean: round(a.sum / a.n),
n: a.n,
}))
.sort((x, y) => x.weekISO.localeCompare(y.weekISO));
const monthly: MonthlyBucket[] = Array.from(monthlyAgg.entries())
.map(([month, a]) => ({
month,
min: round(a.min),
max: round(a.max),
mean: round(a.sum / a.n),
n: a.n,
}))
.sort((x, y) => x.month.localeCompare(y.month));
const yearly: YearlyBucket[] = Array.from(yearlyAgg.entries())
.map(([year, a]) => ({
year,
min: round(a.min),
max: round(a.max),
mean: round(a.sum / a.n),
n: a.n,
// Order the readings oldest → newest for the slope. The map kept
// insertion order; `points` arrives caller-ordered, so re-sort by
// pushing in chronological order is unnecessary — the slope is a
// direction signal, not a calibrated rate.
slope: leastSquaresSlope(yearlyValues.get(year) ?? []),
}))
.sort((x, y) => x.year.localeCompare(y.year));
return { recent, weekly, monthly, yearly };
}
/**
* Project a rollup bucket row to a monthly/yearly graded bucket. The
* tier already carries min/max/mean/slope per bucket — no JS folding.
*/
function rollupMonthly(rows: RollupBucketRow[]): MonthlyBucket[] {
return rows.map((r) => ({
month: berlinMonthKey(r.bucketStart),
min: round(r.minValue),
max: round(r.maxValue),
mean: round(r.mean),
n: r.count,
}));
}
function rollupYearly(rows: RollupBucketRow[]): YearlyBucket[] {
return rows.map((r) => ({
year: berlinYearKey(r.bucketStart),
min: round(r.minValue),
max: round(r.maxValue),
mean: round(r.mean),
n: r.count,
slope: r.slope === null ? null : round(r.slope, 4),
}));
}
/**
* Build the graded series with the recent / weekly slices folded from a
* bounded raw read and the monthly / yearly slices read from the
* pre-aggregated rollup tier.
*
* The recent / weekly read is capped to the recent + weekly horizon
* (last ~90 days), so the common warm-tier path never pulls the full
* multi-year history into memory. The rollup router picks MONTH for the
* ~1-year window and YEAR for the multi-year tail.
*
* Coverage-miss fallback: when the tier has no MONTH (resp. YEAR)
* coverage for a metric — a fresh account the boot-backfill has not yet
* caught up on — the bounded ~90-day read can NOT supply the monthly /
* yearly slices (every row in it folds into recent / weekly), so naively
* reusing it would ship empty monthly / yearly arrays even when years of
* raw history exist. On that miss this falls back to a full-history
* in-memory fold so the coarse slices are populated from the raw rows
* rather than left falsely empty. The full read only happens when the
* tier is cold, which is exactly the case the write-amplified tier was
* meant to avoid — so the hot path stays bounded and the cold path stays
* correct.
*/
export async function buildGradedSeriesWithRollups(
userId: string,
type: MeasurementType,
now: Date,
): Promise<GradedSeries> {
const since = new Date(now.getTime() - WEEKLY_WINDOW_MS);
const rawRecent = await prisma.measurement.findMany({
where: { userId, type, deletedAt: null, measuredAt: { gte: since } },
orderBy: { measuredAt: "asc" },
select: { measuredAt: true, value: true },
});
const recentGraded = buildGradedSeriesFromPoints(
rawRecent.map((r) => ({ measuredAt: r.measuredAt, value: r.value })),
now,
);
// Monthly slice: route a ~1-year window through the tier.
const monthlyRouted = await readBestGranularityRollups(userId, type, 365);
// Yearly slice: route a multi-year window through the tier.
const yearlyRouted = await readBestGranularityRollups(userId, type, 1095);
const monthlyCovered =
monthlyRouted !== null && monthlyRouted.granularity === "MONTH";
const yearlyCovered =
yearlyRouted !== null && yearlyRouted.granularity === "YEAR";
let monthly: MonthlyBucket[];
let yearly: YearlyBucket[];
if (monthlyCovered) {
// Drop the months already covered by recent/weekly (last ~90 d).
const cutoff = new Date(now.getTime() - WEEKLY_WINDOW_MS);
monthly = rollupMonthly(
monthlyRouted.rows.filter((r) => r.bucketStart < cutoff),
).slice(-MONTHLY_MONTHS);
} else {
monthly = [];
}
if (yearlyCovered) {
// Keep only years older than the monthly horizon.
const cutoff = new Date(now.getTime() - MONTHLY_WINDOW_MS);
yearly = rollupYearly(
yearlyRouted.rows.filter((r) => r.bucketStart < cutoff),
);
} else {
yearly = [];
}
// Tier coverage miss for one or both coarse slices: the bounded
// ~90-day read can't supply them, so fold the FULL history in memory
// and take whichever slices the tier left empty. The full read is the
// exception (cold-tier accounts), never the warm-tier norm.
if (!monthlyCovered || !yearlyCovered) {
const allRows = await prisma.measurement.findMany({
where: { userId, type, deletedAt: null },
orderBy: { measuredAt: "asc" },
select: { measuredAt: true, value: true },
});
const fullGraded = buildGradedSeriesFromPoints(
allRows.map((r) => ({ measuredAt: r.measuredAt, value: r.value })),
now,
);
if (!monthlyCovered) monthly = fullGraded.monthly;
if (!yearlyCovered) yearly = fullGraded.yearly;
}
return {
recent: recentGraded.recent,
weekly: recentGraded.weekly,
monthly,
yearly,
};
}
/**
* Apply a constant multiplier to every value in a graded series, keeping
* the bucket keys / counts intact. BMI = weight ÷ height² is a linear
* transform of weight by a per-user constant, so the BMI status card can
* read the WEIGHT rollup tier and scale it rather than maintaining a
* separate (non-existent) BMI tier — `mean / min / max / slope` all carry
* the factor exactly, so the scaled series is identical to folding the
* derived BMI points directly.
*/
export function scaleGradedSeries(
series: GradedSeries,
factor: number,
digits = 2,
): GradedSeries {
const r = (v: number) => round(v * factor, digits);
return {
recent: series.recent.map((b) => ({
...b,
min: r(b.min),
max: r(b.max),
mean: r(b.mean),
})),
weekly: series.weekly.map((b) => ({
...b,
min: r(b.min),
max: r(b.max),
mean: r(b.mean),
})),
monthly: series.monthly.map((b) => ({
...b,
min: r(b.min),
max: r(b.max),
mean: r(b.mean),
})),
yearly: series.yearly.map((b) => ({
...b,
min: r(b.min),
max: r(b.max),
mean: r(b.mean),
slope: b.slope === null ? null : round(b.slope * factor, 4),
})),
};
}
/**
* Assembled-snapshot soft char cap, mirroring the Coach's
* `MAX_SNAPSHOT_CHARS` (~24 000 chars ≈ ~6 000 tokens against the
* pretty-printed form). The status snapshots had no cap at all; a
* many-metric `general-status` could balloon past 100 K tokens.
*/
export const MAX_SNAPSHOT_CHARS = 24_000;
/**
* Walk an arbitrary snapshot object and, while it exceeds
* `MAX_SNAPSHOT_CHARS` (measured against the pretty-printed form the
* prompt ships), shed the lowest-signal slices in order:
* 1. drop every graded `yearly` array,
* 2. drop every graded `weekly` array,
* 3. truncate any other array to its tail `arrayTailCap` entries
* (correlation pair arrays, paired-daily rows, …),
* 4. drop every graded `recent` array (last resort — the coarse
* monthly summary survives).
*
* Mutates `snapshot` in place and returns the slices it shed so the
* caller can annotate. Generic by design: it keys off the graded field
* names + array length, never off a specific generator's shape.
*/
export function degradeStatusSnapshotToBudget(
snapshot: Record<string, unknown>,
arrayTailCap = 30,
): string[] {
const shed: string[] = [];
const size = () => JSON.stringify(snapshot, null, 2).length;
if (size() <= MAX_SNAPSHOT_CHARS) return shed;
const walk = (
node: unknown,
fn: (parent: Record<string, unknown>, key: string, value: unknown) => void,
): void => {
if (Array.isArray(node)) {
for (const item of node) walk(item, fn);
return;
}
if (node && typeof node === "object") {
const obj = node as Record<string, unknown>;
for (const key of Object.keys(obj)) {
fn(obj, key, obj[key]);
walk(obj[key], fn);
}
}
};
const dropField = (field: string) => {
walk(snapshot, (parent, key) => {
if (key === field && Array.isArray(parent[key])) {
delete parent[key];
}
});
};
dropField("yearly");
if (size() <= MAX_SNAPSHOT_CHARS) {
shed.push("yearly");
return shed;
}
shed.push("yearly");
dropField("weekly");
if (size() <= MAX_SNAPSHOT_CHARS) {
shed.push("weekly");
return shed;
}
shed.push("weekly");
// Truncate every remaining non-graded array to its tail.
walk(snapshot, (parent, key, value) => {
if (
Array.isArray(value) &&
key !== "recent" &&
value.length > arrayTailCap
) {
parent[key] = value.slice(-arrayTailCap);
}
});
if (size() <= MAX_SNAPSHOT_CHARS) {
shed.push("array-tails");
return shed;
}
shed.push("array-tails");
dropField("recent");
shed.push("recent");
return shed;
}
+109 -171
View File
@@ -1,5 +1,4 @@
import { prisma } from "@/lib/db";
import { resolveProvider } from "@/lib/ai/provider";
import {
getMedicationComplianceSystemPrompt,
getMedicationComplianceUserPrompt,
@@ -18,12 +17,18 @@ import {
getPreviousInsightContext,
} from "@/lib/insights/memory";
import { applyPayloadBudget } from "@/lib/insights/bucket-series";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import { buildGradedSeriesFromPoints } from "@/lib/insights/graded-series";
import { degradeStatusSnapshotToBudget } from "@/lib/insights/graded-series";
import {
withTimeout,
STATUS_PROVIDER_TIMEOUT_MS,
} from "@/lib/insights/with-timeout";
import { persistTimeoutStubAndReturn } from "@/lib/insights/persist-timeout-stub";
type SupportedLocale,
normalizeLocale,
normalizeSummaryText,
parseSummaryFromContent,
round,
} from "@/lib/insights/status-shared";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { isTimeoutStub } from "@/lib/insights/status-cache";
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
import { annotate } from "@/lib/logging/context";
import { toBerlinDayKey } from "@/lib/tz/resolver";
@@ -31,26 +36,11 @@ import { toBerlinDayKey } from "@/lib/tz/resolver";
const COMPLIANCE_HISTORY_DAYS = 360 + 24 * 30;
const MS_PER_DAY = 24 * 60 * 60 * 1000;
type SupportedLocale = "de" | "en";
interface MedicationSummaryItem {
medicationId: string;
text: string;
}
function round(value: number, digits = 1): number {
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
}
function normalizeSummaryText(value: string): string {
return stripChartTokens(value).replace(/\s+/g, " ").trim();
}
function normalizeLocale(value: string | null | undefined): SupportedLocale {
return value === "en" ? "en" : "de";
}
export async function generateMedicationComplianceStatusForUser(
userId: string,
options?: {
@@ -69,73 +59,50 @@ export async function generateMedicationComplianceStatusForUser(
const cacheAction = `insights.medication-compliance-status.${locale}`;
const todayKey = toBerlinDayKey(new Date());
const provider = await resolveProvider(userId);
if (provider.type === "none") {
return {
hasProvider: false,
summary: getNoKeyMedicationComplianceStatusText(locale),
medications: [],
cached: true,
updatedAt: null,
};
}
const latestCache = await prisma.auditLog.findFirst({
where: { userId, action: cacheAction },
orderBy: { createdAt: "desc" },
select: { createdAt: true, details: true },
});
if (!force && latestCache?.details) {
try {
const parsed = JSON.parse(latestCache.details) as {
dateKey?: string;
summary?: string;
text?: string;
timeout?: boolean;
medications?: MedicationSummaryItem[];
};
if (
parsed.dateKey === todayKey &&
typeof parsed.summary === "string" &&
parsed.summary.trim().length > 0 &&
Array.isArray(parsed.medications)
) {
return {
hasProvider: true,
summary: parsed.summary,
medications: parsed.medications.filter(
(entry): entry is MedicationSummaryItem =>
typeof entry?.medicationId === "string" &&
typeof entry?.text === "string" &&
entry.text.trim().length > 0,
),
cached: true,
updatedAt: latestCache.createdAt.toISOString(),
// This route carries a richer cached envelope (`summary` +
// `medications`) than the standard `text`-only generators, so it
// keeps its own cache-read — but it shares the stub-rejection
// predicate so a timeout stub never sticks for the day.
if (!force) {
const latestCache = await prisma.auditLog.findFirst({
where: { userId, action: cacheAction },
orderBy: { createdAt: "desc" },
select: { createdAt: true, details: true },
});
if (latestCache?.details) {
try {
const parsed = JSON.parse(latestCache.details) as {
dateKey?: string;
summary?: string;
text?: string;
model?: string;
timeout?: boolean;
medications?: MedicationSummaryItem[];
};
}
// v1.4.41 — timeout-stub rows persist `text` + `timeout: true`
// instead of the standard `summary` + `medications` envelope.
// Recognise the stub so the second mount short-circuits at the
// cache lookup instead of re-racing the provider call.
if (
parsed.dateKey === todayKey &&
parsed.timeout === true &&
typeof parsed.text === "string" &&
parsed.text.trim().length > 0
) {
return {
hasProvider: true,
summary: parsed.text,
medications: [],
cached: true,
updatedAt: latestCache.createdAt.toISOString(),
};
if (
parsed.dateKey === todayKey &&
!isTimeoutStub(parsed) &&
typeof parsed.summary === "string" &&
parsed.summary.trim().length > 0 &&
Array.isArray(parsed.medications)
) {
return {
hasProvider: true,
summary: parsed.summary,
medications: parsed.medications.filter(
(entry): entry is MedicationSummaryItem =>
typeof entry?.medicationId === "string" &&
typeof entry?.text === "string" &&
entry.text.trim().length > 0,
),
cached: true,
updatedAt: latestCache.createdAt.toISOString(),
};
}
} catch {
// ignore invalid cache payload
}
} catch {
// ignore invalid cache payload
}
}
@@ -154,7 +121,7 @@ export async function generateMedicationComplianceStatusForUser(
: "There are currently no active medications configured.",
medications: [],
cached: true,
updatedAt: latestCache?.createdAt.toISOString() ?? null,
updatedAt: null,
};
}
@@ -244,8 +211,11 @@ export async function generateMedicationComplianceStatusForUser(
value: round(Math.min(100, (stats.taken / expectedPerDay) * 100), 1),
}));
const dailySeries = applyPayloadBudget(perDayRecords, { now });
const latestDay = dailySeries.daily[0] ?? null;
// `applyPayloadBudget` gives the latest-day focus; the compact
// graded series is what reaches the prompt.
const dailyBudgeted = applyPayloadBudget(perDayRecords, { now });
const dailySeries = buildGradedSeriesFromPoints(perDayRecords, now);
const latestDay = dailyBudgeted.daily[0] ?? null;
return {
medicationId: medication.id,
@@ -315,11 +285,17 @@ export async function generateMedicationComplianceStatusForUser(
medications: medicationSnapshots,
};
const shed = degradeStatusSnapshotToBudget(
snapshot as unknown as Record<string, unknown>,
);
const snapshotJson = JSON.stringify(snapshot, null, 2);
annotate({
action: { name: cacheAction },
meta: { payload_size_bytes: snapshotJson.length },
meta: {
payload_size_bytes: snapshotJson.length,
...(shed.length > 0 ? { snapshot_shed: shed } : {}),
},
});
const previousContext = await getPreviousInsightContext(
@@ -333,100 +309,62 @@ export async function generateMedicationComplianceStatusForUser(
locale,
);
// v1.4.28 FB-D2 — 20 s timeout race; fall back to the no-key text
// on stall so the InsightStatusCard renders deterministically.
const raced = await withTimeout(
() =>
provider.generateCompletion({
systemPrompt: getMedicationComplianceSystemPrompt(locale),
userPrompt: getMedicationComplianceUserPrompt(
snapshotJson,
todayKey,
locale,
previousContextBlock,
),
temperature: 0.3,
maxTokens: 1000,
}),
STATUS_PROVIDER_TIMEOUT_MS,
null,
);
if (raced.timedOut || raced.value === null) {
// v1.4.37 — persist a sentinel row keyed to today so the next
// mount short-circuits at the cache lookup above instead of
// re-racing the same 20 s provider call on every cold visit.
// See `persistTimeoutStubAndReturn` for the full rationale.
// This route returns a richer shape (`summary`, `medications`)
// than the standard `text` envelope, so the helper is called
// for its persist side-effect and the route maps `summary` ←
// `text` on the way out.
const stubReturn = await persistTimeoutStubAndReturn({
userId,
cacheAction,
const outcome = await runStatusCompletion({
userId,
cacheAction,
systemPrompt: getMedicationComplianceSystemPrompt(locale),
userPrompt: getMedicationComplianceUserPrompt(
snapshotJson,
todayKey,
locale,
providerType: provider.type,
previousContextBlock,
),
temperature: 0.3,
maxTokens: 1000,
});
if (outcome.kind === "none") {
return {
hasProvider: false,
summary: getNoKeyMedicationComplianceStatusText(locale),
medications: [],
cached: true,
updatedAt: null,
};
}
if (outcome.kind === "timeout" || outcome.kind === "error") {
// Transient miss — serve the fallback for this render without
// persisting it, so the next mount re-attempts a real generation.
returnTimeoutFallback({
cacheAction,
reason: outcome.kind,
stubText: getNoKeyMedicationComplianceStatusText(locale),
});
return {
hasProvider: true,
summary: stubReturn.text,
summary: getNoKeyMedicationComplianceStatusText(locale),
medications: [],
cached: true,
updatedAt: stubReturn.updatedAt,
updatedAt: null,
};
}
const result = raced.value;
const content = result.content;
if (typeof content !== "string" || content.trim().length === 0) {
throw new Error(
"AI returned empty content for medication-compliance-status",
);
}
let summary = "";
let medicationSummaries: MedicationSummaryItem[] = [];
try {
const parsed = JSON.parse(content) as {
summary?: string;
medications?: Array<{ medicationId?: string; summary?: string }>;
};
summary = typeof parsed.summary === "string" ? parsed.summary : content;
const incomingMap = new Map<string, string>();
for (const entry of parsed.medications ?? []) {
if (
typeof entry?.medicationId === "string" &&
typeof entry?.summary === "string" &&
entry.summary.trim().length > 0
) {
incomingMap.set(entry.medicationId, entry.summary);
}
}
medicationSummaries = medicationSnapshots.map((medication) => ({
// The compliance prompt returns a single `{ summary }` envelope — it
// does not emit per-medication text. The per-medication cards carry a
// placeholder so the UI surfaces a row per active medication; the
// overall `summary` is the model-authored assessment.
const summary = normalizeSummaryText(parseSummaryFromContent(outcome.content));
const medicationSummaries: MedicationSummaryItem[] = medicationSnapshots.map(
(medication) => ({
medicationId: medication.medicationId,
text: normalizeSummaryText(
incomingMap.get(medication.medicationId) ??
(locale === "de"
? `${medication.name}: Es liegen noch nicht genügend konsistente Detaildaten für eine belastbare Kurzbewertung vor.`
: `${medication.name}: There is currently not enough consistent detail data for a robust short assessment.`),
),
}));
} catch {
summary = content;
medicationSummaries = medicationSnapshots.map((medication) => ({
medicationId: medication.medicationId,
text:
locale === "de"
? `${medication.name}: Die medikamentenspezifische Kurzbewertung konnte heute nicht separat aufbereitet werden.`
: `${medication.name}: The medication-specific short assessment could not be prepared separately today.`,
}));
}
? `${medication.name}: Es liegen noch nicht genügend konsistente Detaildaten für eine belastbare Kurzbewertung vor.`
: `${medication.name}: There is currently not enough consistent detail data for a robust short assessment.`,
),
}),
);
summary = normalizeSummaryText(summary);
if (!summary) {
throw new Error(
"Medication-compliance-status summary was empty after normalization",
@@ -442,9 +380,9 @@ export async function generateMedicationComplianceStatusForUser(
locale,
summary,
medications: medicationSummaries,
providerType: provider.type,
model: result.model ?? "unknown",
tokensUsed: result.tokensUsed ?? null,
providerType: outcome.providerType,
model: outcome.model,
tokensUsed: outcome.tokensUsed,
}),
},
select: { createdAt: true },
+78 -158
View File
@@ -1,5 +1,4 @@
import { prisma } from "@/lib/db";
import { resolveProvider } from "@/lib/ai/provider";
import { getMoodSystemPrompt, getMoodUserPrompt } from "@/lib/ai/prompts/mood";
import { getNoKeyMoodStatusText } from "@/lib/insights/no-key-fallbacks";
import {
@@ -15,60 +14,25 @@ import {
dayOffsetToBerlinDayKey,
type DailyBucket,
} from "@/lib/insights/bucket-series";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import {
withTimeout,
STATUS_PROVIDER_TIMEOUT_MS,
} from "@/lib/insights/with-timeout";
import { persistTimeoutStubAndReturn } from "@/lib/insights/persist-timeout-stub";
buildGradedSeriesFromPoints,
degradeStatusSnapshotToBudget,
} from "@/lib/insights/graded-series";
import {
type SupportedLocale,
normalizeLocale,
normalizeSummaryText,
parseSummaryFromContent,
persistStatusInsight,
round,
summarizeSeries,
} from "@/lib/insights/status-shared";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { readFreshStatusText } from "@/lib/insights/status-cache";
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
import { annotate } from "@/lib/logging/context";
import { toBerlinDayKey } from "@/lib/tz/resolver";
type SupportedLocale = "de" | "en";
function round(value: number, digits = 1): number {
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
}
function normalizeSummaryText(value: string): string {
return stripChartTokens(value).replace(/\s+/g, " ").trim();
}
function normalizeLocale(value: string | null | undefined): SupportedLocale {
return value === "en" ? "en" : "de";
}
function summarizeSeries(series: Array<{ value: number }>) {
if (series.length === 0) return null;
const first = series[0].value;
const last = series[series.length - 1].value;
// v1.4.33 — fold sum/min/max into a single walk. The previous
// `Math.min(...series.map(...))` / `Math.max(...series.map(...))`
// spread tripped V8's ~125 000-arg ceiling on the bound /api/analytics
// path; see `.planning/round-v1433-analytics-500-report.md` §"Carry-
// over". These helpers are fed bounded windows today so the crash
// never reached them, but the spread allocates a transient args array
// on every call — the fold is both stack-safe and cheaper.
let sum = 0;
let minVal = series[0].value;
let maxVal = series[0].value;
for (const entry of series) {
sum += entry.value;
if (entry.value < minVal) minVal = entry.value;
if (entry.value > maxVal) maxVal = entry.value;
}
return {
points: series.length,
start: round(first, 2),
end: round(last, 2),
delta: round(last - first, 2),
mean: round(sum / series.length, 2),
min: round(minVal, 2),
max: round(maxVal, 2),
};
}
/**
* Pair two daily-bucket series on `dayOffset`. The synthesised `date`
* field is anchored at the UTC midnight of the Berlin calendar day —
@@ -118,43 +82,19 @@ export async function generateMoodStatusForUser(
const cacheAction = `insights.mood-status.${locale}`;
const todayKey = toBerlinDayKey(new Date());
const provider = await resolveProvider(userId);
if (provider.type === "none") {
return {
hasProvider: false,
text: getNoKeyMoodStatusText(locale),
cached: true,
updatedAt: null,
};
}
const latestCache = await prisma.auditLog.findFirst({
where: { userId, action: cacheAction },
orderBy: { createdAt: "desc" },
select: { createdAt: true, details: true },
const cached = await readFreshStatusText({
userId,
cacheAction,
todayKey,
force,
});
if (!force && latestCache?.details) {
try {
const parsed = JSON.parse(latestCache.details) as {
dateKey?: string;
text?: string;
};
if (
parsed.dateKey === todayKey &&
typeof parsed.text === "string" &&
parsed.text.trim().length > 0
) {
return {
hasProvider: true,
text: parsed.text,
cached: true,
updatedAt: latestCache.createdAt.toISOString(),
};
}
} catch {
// ignore invalid cache payload
}
if (cached) {
return {
hasProvider: true,
text: cached.text,
cached: true,
updatedAt: cached.updatedAt,
};
}
// v1.4.28 FB-D2 — cap at 90 entries (~3 months) for the mood
@@ -179,13 +119,14 @@ export async function generateMoodStatusForUser(
const now = new Date();
const moodSeries = applyPayloadBudget(
entries.map((entry) => ({
measuredAt: entry.moodLoggedAt,
value: entry.score,
})),
{ now },
);
const moodPoints = entries.map((entry) => ({
measuredAt: entry.moodLoggedAt,
value: entry.score,
}));
// `applyPayloadBudget` daily buckets drive the in-target %, latest,
// and correlations; the compact graded series reaches the prompt.
const moodSeries = applyPayloadBudget(moodPoints, { now });
const moodGraded = buildGradedSeriesFromPoints(moodPoints, now);
const moodSummary = summarizeSeries(
moodSeries.daily.map((bucket) => ({ value: bucket.value })),
);
@@ -329,7 +270,7 @@ export async function generateMoodStatusForUser(
},
mood: {
summary: moodSummary,
series: moodSeries,
series: moodGraded,
latestDayFocus: latestMood
? {
dayOffset: latestMood.dayOffset,
@@ -391,11 +332,17 @@ export async function generateMoodStatusForUser(
: null,
};
const shed = degradeStatusSnapshotToBudget(
snapshot as unknown as Record<string, unknown>,
);
const snapshotJson = JSON.stringify(snapshot, null, 2);
annotate({
action: { name: cacheAction },
meta: { payload_size_bytes: snapshotJson.length },
meta: {
payload_size_bytes: snapshotJson.length,
...(shed.length > 0 ? { snapshot_shed: shed } : {}),
},
});
const previousContext = await getPreviousInsightContext(
@@ -409,84 +356,57 @@ export async function generateMoodStatusForUser(
locale,
);
// v1.4.28 FB-D2 — 20 s timeout race; fall back to the no-key text
// on stall so the InsightStatusCard renders deterministically.
const raced = await withTimeout(
() =>
provider.generateCompletion({
systemPrompt: getMoodSystemPrompt(locale),
userPrompt: getMoodUserPrompt(
snapshotJson,
todayKey,
locale,
previousContextBlock,
),
temperature: 0.3,
maxTokens: 1000,
}),
STATUS_PROVIDER_TIMEOUT_MS,
null,
);
if (raced.timedOut || raced.value === null) {
// v1.4.37 — persist a sentinel row keyed to today so the next
// mount short-circuits at the cache lookup above instead of
// re-racing the same 20 s provider call on every cold visit.
// See `persistTimeoutStubAndReturn` for the full rationale.
return persistTimeoutStubAndReturn({
userId,
cacheAction,
const outcome = await runStatusCompletion({
userId,
cacheAction,
systemPrompt: getMoodSystemPrompt(locale),
userPrompt: getMoodUserPrompt(
snapshotJson,
todayKey,
locale,
providerType: provider.type,
previousContextBlock,
),
temperature: 0.3,
maxTokens: 1000,
});
if (outcome.kind === "none") {
return {
hasProvider: false,
text: getNoKeyMoodStatusText(locale),
cached: true,
updatedAt: null,
};
}
if (outcome.kind === "timeout" || outcome.kind === "error") {
return returnTimeoutFallback({
cacheAction,
reason: outcome.kind,
stubText: getNoKeyMoodStatusText(locale),
});
}
const result = raced.value;
const content = result.content;
if (typeof content !== "string" || content.trim().length === 0) {
throw new Error("AI returned empty content for mood-status");
}
let summary = "";
try {
const parsed = JSON.parse(content) as { summary?: string };
if (typeof parsed.summary === "string") {
summary = parsed.summary;
} else {
summary = content;
}
} catch {
summary = content;
}
summary = normalizeSummaryText(summary);
const summary = normalizeSummaryText(parseSummaryFromContent(outcome.content));
if (!summary) {
throw new Error("Mood-status summary was empty after normalization");
}
const created = await prisma.auditLog.create({
data: {
userId,
action: cacheAction,
details: JSON.stringify({
dateKey: todayKey,
locale,
text: summary,
providerType: provider.type,
model: result.model ?? "unknown",
tokensUsed: result.tokensUsed ?? null,
}),
},
select: { createdAt: true },
const updatedAt = await persistStatusInsight({
userId,
cacheAction,
todayKey,
locale,
text: summary,
providerType: outcome.providerType,
model: outcome.model,
tokensUsed: outcome.tokensUsed,
});
return {
hasProvider: true,
text: summary,
cached: false,
updatedAt: created.createdAt.toISOString(),
updatedAt,
};
}
-73
View File
@@ -1,73 +0,0 @@
import { prisma } from "@/lib/db";
/**
* v1.4.37 / v1.4.41 — shared timeout-stub persist for InsightStatus routes.
*
* When the upstream provider call exceeds `STATUS_PROVIDER_TIMEOUT_MS`
* the caller persists a sentinel `auditLog` row keyed to today's Berlin
* day so the next mount short-circuits at the cache lookup instead of
* re-racing the same 20 s provider call on every cold visit. Without
* this, a single stall (provider hiccup, network blip, model warm-up)
* leaves the user staring at the loading state on every reload until
* the daily 02:20 pre-warm job runs.
*
* The persisted body is the deterministic no-key fallback the caller
* would have returned in the bare-fallback shape, so the user-facing
* UI is identical; only the re-fire frequency drops to zero for the
* rest of the day.
*
* `meta.timeout` + `meta.model = "timeout-stub"` are set so the daily
* pg-boss pre-warm worker (the same job that originally seeded the
* cache) can recognise and overwrite the stub rather than respect it
* as a real assessment.
*
* The write is best-effort — if it fails the caller still sees the
* deterministic fallback text and the next mount falls back to the
* (still expensive) race. We do not surface the error: the user does
* not care that the cache write missed, only that the page rendered.
*/
export async function persistTimeoutStubAndReturn(input: {
userId: string;
cacheAction: string;
todayKey: string;
locale: string;
providerType: string;
stubText: string;
}): Promise<{
hasProvider: true;
text: string;
cached: true;
updatedAt: string | null;
}> {
const { userId, cacheAction, todayKey, locale, providerType, stubText } =
input;
let stubUpdatedAt: string | null = null;
try {
const stub = await prisma.auditLog.create({
data: {
userId,
action: cacheAction,
details: JSON.stringify({
dateKey: todayKey,
locale,
text: stubText,
providerType,
model: "timeout-stub",
tokensUsed: null,
timeout: true,
}),
},
select: { createdAt: true },
});
stubUpdatedAt = stub.createdAt.toISOString();
} catch {
// The persist is best-effort — silently swallow so the user-facing
// payload still renders even when the row write missed.
}
return {
hasProvider: true,
text: stubText,
cached: true,
updatedAt: stubUpdatedAt,
};
}
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff. Show more