mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 12:22:03 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a603ad587 | ||
|
|
710902a43c | ||
|
|
5047eb7a20 | ||
|
|
0d7d08f616 | ||
|
|
4578078cc1 | ||
|
|
7f7bf75e34 | ||
|
|
fcb517df87 | ||
|
|
869f13612a | ||
|
|
3743f91bce | ||
|
|
013ad5d967 | ||
|
|
aed83b7747 | ||
|
|
c6e782cdb8 | ||
|
|
6842b52346 | ||
|
|
67a72b1023 | ||
|
|
c775861d8c | ||
|
|
eee6ab2e7e | ||
|
|
75380d5f39 | ||
|
|
36d5c79526 | ||
|
|
05097df1ee | ||
|
|
8f120cfdef | ||
|
|
f683e8fd6d | ||
|
|
0e9e9bc4ba | ||
|
|
4c873ebb37 | ||
|
|
d29b11cdf4 | ||
|
|
262a3ad70a | ||
|
|
607dd9e5df | ||
|
|
c4ccd7e297 | ||
|
|
b416cabbf1 | ||
|
|
56cff68ec5 | ||
|
|
0f25226fdd | ||
|
|
4e04e375ad | ||
|
|
052bbbfd7c | ||
|
|
4f48160b3d | ||
|
|
0f9fc70440 | ||
|
|
cd8849cd51 | ||
|
|
177ce576d3 | ||
|
|
68a9f114a5 | ||
|
|
11a06196f3 | ||
|
|
1b78df345d | ||
|
|
5432063f5c | ||
|
|
16b4768fd1 |
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.
|
||||
@@ -1,5 +1,54 @@
|
||||
# Changelog
|
||||
|
||||
## [1.8.2] — 2026-06-01 — One intake row per dose slot
|
||||
|
||||
A multi-time medication could end up with two entries for the same dose — one still open, one marked taken — for a dose the user had not taken. This release guarantees a single entry per scheduled dose.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A dose slot can no longer hold a duplicate entry.** Logging a dose now updates the slot's existing entry in place instead of inserting a second row, regardless of where the log came from, and the logged time is aligned to the scheduled dose rather than the exact moment of the tap. A twice-daily medication no longer shows the same time twice (once open, once taken), the daily count is no longer inflated, and the "due now" prompt is no longer suppressed for a dose that has not actually been taken.
|
||||
- **A recorded dose is never silently un-recorded.** A background re-sync that replays an open dose onto a slot already marked taken is now ignored rather than clearing the taken mark, so an offline sync can never flip a taken dose back to missed.
|
||||
- **Concurrent logs converge.** Two logs landing on the same dose at once resolve to one entry instead of failing or duplicating.
|
||||
- **Existing duplicates are cleaned up on startup.** A one-time pass collapses any duplicate dose entries already stored, keeping the recorded dose and correcting the affected compliance figures.
|
||||
|
||||
## [1.8.1] — 2026-06-01 — Multi-time medication compliance
|
||||
|
||||
A follow-up to the multi-time compliance work in 1.7.3, closing the two surfaces it did not reach.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Every scheduled time of a multi-time medication is now loggable** — a medication taken more than once a day on a single schedule with several times (e.g. 07:00 and 19:00) surfaced only its first dose in the today tile and the logging sheet, so the second dose could never be recorded and the day's compliance was counted against a single expected dose. The today-window projection now creates one pending dose per scheduled time, matching how reminders are issued.
|
||||
- **The compliance heatmap classifies each dose against its own time** — a twice-daily dose logged in the evening was measured against the morning time and read as very late, painting the day amber even when both doses were taken on time. Each logged dose is now matched to its nearest scheduled time, so a day with every dose taken on time reads as on time. Single-time schedules are unaffected.
|
||||
|
||||
## [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.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Health-record export renders in the account language** — the PDF came out in English regardless of the in-app language because the export only consulted the browser's `Accept-Language`. The client now sends the active language and the server falls back to the language cookie before the browser header.
|
||||
- **Health-record export layout** — charts now label their time axis (start and end date of the plotted window), modules are kept together instead of breaking across a page, a consistent bottom margin is held on every page, and a glyph-encoding issue that stretched a few lines (trend arrows and the `m²` unit) is resolved by sanitising to the font's supported characters.
|
||||
- **Compliance counts every scheduled time on multi-time plans** — a medication taken twice a day on a weekday plan with two times but no recurrence rule reported 50% even when both doses were logged: the expected-dose count and the taken count expanded the schedule through two different code paths. Both now run through the one canonical recurrence engine, so the rate is correct for every schedule shape.
|
||||
|
||||
## [1.7.2] — 2026-05-31 — Coach source parity, first-paint snapshot, medication card cleanup
|
||||
|
||||
A focused follow-up to v1.7.0. The unified dashboard snapshot that shipped in v1.7.0 is now the default, so a cold dashboard paints its above-the-fold tiles together instead of letting the mood tile arrive ahead of the rest. The Coach's in-chat data-source rail now reads and writes the same persisted data clusters as the settings sheet — one source of truth that survives a reload, so what the panel shows always matches what reaches the model. The medication overview cards collapse their action row into a single overflow menu, and the detail page is pared back to the intake history under a read-only plan summary. The advanced-settings sheet widens into a two-column layout and offers a medications export beside the existing import.
|
||||
|
||||
@@ -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
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.7.2
|
||||
version: 1.8.2
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.7.2",
|
||||
"version": "1.8.2",
|
||||
"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",
|
||||
|
||||
@@ -623,6 +623,68 @@ describe("GET /api/dashboard/summary", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("projects one row per timesOfDay for a twice-daily schedule", async () => {
|
||||
// A single MedicationSchedule row carrying two first-class
|
||||
// `timesOfDay` is two distinct dose slots per day. Pre-fix the
|
||||
// projector minted only the `windowStart` slot, so the second daily
|
||||
// dose never appeared in the today-tile and the event-count
|
||||
// compliance rollup read half the expected doses (a 2×/day med
|
||||
// showed 50%). Assert the backfill mints both slots.
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-21T03:00:00.000Z"));
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "med-twice-daily",
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
schedules: [
|
||||
{
|
||||
id: "sched-bid",
|
||||
medicationId: "med-twice-daily",
|
||||
windowStart: "07:00",
|
||||
windowEnd: "07:30",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: ["07:00", "19:00"],
|
||||
reminderGraceMinutes: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockImplementation(((
|
||||
args: unknown,
|
||||
) => {
|
||||
const a = args as { where: { OR?: unknown }; select?: { id?: boolean } };
|
||||
if (a.where.OR) return Promise.resolve([]) as never;
|
||||
if (!a.select?.id) return Promise.resolve([]) as never;
|
||||
return Promise.resolve([]) as never;
|
||||
}) as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.createMany).mockResolvedValue({
|
||||
count: 2,
|
||||
} as never);
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.medicationIntakeEvent.createMany).toHaveBeenCalledTimes(1);
|
||||
const args = vi.mocked(prisma.medicationIntakeEvent.createMany).mock
|
||||
.calls[0][0] as {
|
||||
data: Array<{ medicationId: string; scheduledFor: Date }>;
|
||||
};
|
||||
expect(args.data).toHaveLength(2);
|
||||
const hours = args.data
|
||||
.map((r) => new Date(r.scheduledFor).getUTCHours())
|
||||
.sort((a, b) => a - b);
|
||||
// 07:00 and 19:00 in the default zone (Europe/Berlin, UTC+2 in May)
|
||||
// materialise to 05:00 and 17:00 UTC. The assertion that matters is
|
||||
// two distinct slots, not one.
|
||||
expect(hours).toEqual([5, 17]);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("is idempotent on a second pass (v1.4.39 W-SERVER-FIX-2)", async () => {
|
||||
// When the existence probe already sees a row for the projected
|
||||
// slot (e.g. the intake route or the reminder worker minted it
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
import { PDFParse } from "pdf-parse";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
@@ -38,14 +39,27 @@ const SESSION_OK = {
|
||||
user: { id: "user-1", email: "test@example.com", role: "USER" },
|
||||
} as const;
|
||||
|
||||
function mkReq(body: unknown): NextRequest {
|
||||
function mkReq(
|
||||
body: unknown,
|
||||
headers: Record<string, string> = {},
|
||||
): NextRequest {
|
||||
return new NextRequest("http://localhost/api/export/health-record", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
headers: { "content-type": "application/json", ...headers },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function pdfText(res: Response): Promise<string> {
|
||||
const bytes = new Uint8Array(await res.arrayBuffer());
|
||||
const parser = new PDFParse({ data: bytes });
|
||||
try {
|
||||
return (await parser.getText()).text;
|
||||
} finally {
|
||||
await parser.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
@@ -137,6 +151,46 @@ describe("POST /api/export/health-record — outputs", () => {
|
||||
expect(buf.subarray(0, 2).toString("latin1")).toBe("PK");
|
||||
});
|
||||
|
||||
it("renders the PDF in the explicit selection locale", async () => {
|
||||
const { POST } = await import("../route");
|
||||
const res = await POST(
|
||||
mkReq({ format: "pdf", locale: "de", practiceName: "Sample Practice" }),
|
||||
);
|
||||
const text = await pdfText(res);
|
||||
// German cover label.
|
||||
expect(text).toContain("Praxis:");
|
||||
});
|
||||
|
||||
it("falls back to the healthlog-locale cookie when no locale is sent", async () => {
|
||||
const { POST } = await import("../route");
|
||||
const res = await POST(
|
||||
mkReq(
|
||||
{ format: "pdf", practiceName: "Sample Practice" },
|
||||
{
|
||||
// Browser default English, but the in-app cookie says German — the
|
||||
// cookie must win over Accept-Language.
|
||||
"accept-language": "en-US,en;q=0.9",
|
||||
cookie: "healthlog-locale=de",
|
||||
},
|
||||
),
|
||||
);
|
||||
const text = await pdfText(res);
|
||||
expect(text).toContain("Praxis:");
|
||||
expect(text).not.toContain("Practice:");
|
||||
});
|
||||
|
||||
it("uses Accept-Language only when neither selection nor cookie is present", async () => {
|
||||
const { POST } = await import("../route");
|
||||
const res = await POST(
|
||||
mkReq(
|
||||
{ format: "pdf", practiceName: "Sample Practice" },
|
||||
{ "accept-language": "en-US,en;q=0.9" },
|
||||
),
|
||||
);
|
||||
const text = await pdfText(res);
|
||||
expect(text).toContain("Practice:");
|
||||
});
|
||||
|
||||
it("scopes the aggregator measurement read to the session user", async () => {
|
||||
const { POST } = await import("../route");
|
||||
await POST(mkReq({ format: "fhir", userId: "user-2" } as never));
|
||||
|
||||
@@ -46,7 +46,7 @@ import {
|
||||
} from "@/lib/validations/health-record-export";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale";
|
||||
import { type Locale } from "@/lib/i18n/config";
|
||||
import { locales, type Locale } from "@/lib/i18n/config";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
@@ -72,8 +72,20 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const sections = toDoctorReportPrefs(selection.sections);
|
||||
const includeCharts = selection.includeCharts ?? true;
|
||||
|
||||
// Locale resolution, most-specific first: the explicit selection wins, then
|
||||
// the in-app `healthlog-locale` cookie (what the user actually chose in the
|
||||
// UI), and only then the browser's Accept-Language header. The header is the
|
||||
// weakest signal — a German user on an English-default browser would
|
||||
// otherwise get an English report.
|
||||
const cookieValue = request.cookies.get("healthlog-locale")?.value;
|
||||
const cookieLocale = (locales as readonly string[]).includes(
|
||||
cookieValue ?? "",
|
||||
)
|
||||
? (cookieValue as Locale)
|
||||
: null;
|
||||
const locale: Locale =
|
||||
selection.locale ??
|
||||
cookieLocale ??
|
||||
parseLocaleFromAcceptLanguage(
|
||||
request.headers.get("accept-language") ?? "",
|
||||
);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,241 @@
|
||||
/**
|
||||
* v1.8.1 B15 follow-on — per-slot timing classification for the History
|
||||
* compliance heatmap.
|
||||
*
|
||||
* The `dailyCompliance` builder in `GET /api/medications/[id]/compliance`
|
||||
* classifies each taken intake into early / on_time / late / very_late
|
||||
* buckets and paints the heatmap cell off those counts. The bug: a
|
||||
* twice-daily medication stored as ONE schedule row
|
||||
* (`timesOfDay = ["07:00","19:00"]`, `windowStart = "07:00"`) matched the
|
||||
* 19:00 dose against the single `windowStart` "07:00" — classifying a
|
||||
* perfectly on-time evening dose as `very_late` (~12h late) and painting
|
||||
* the cell orange even though both doses were taken on time.
|
||||
*
|
||||
* The fix matches each taken event to the closest effective time-of-day
|
||||
* SLOT across every schedule (candidate slots = `timesOfDay` when
|
||||
* non-empty, else `[windowStart]`) and classifies against that slot's
|
||||
* window (slot..slot+span, span = windowEnd − windowStart). Single-time
|
||||
* schedules stay byte-identical to the pre-fix behaviour.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
medication: { findUnique: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/medications/route-guards", () => ({
|
||||
assertMedicationOwnership: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { getUserTodayBounds } from "@/lib/timezone";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
|
||||
// UTC keeps the half-open day slice (`[dayStart, dayEnd)`) and the
|
||||
// classifier's window anchor (`setUTCHours` on the day-start date) in the
|
||||
// same reference frame, so the fixture instants below land deterministically
|
||||
// in range regardless of the host timezone.
|
||||
const TZ = "UTC";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const, timezone: TZ },
|
||||
};
|
||||
|
||||
const ROUTE_PARAMS = { params: Promise.resolve({ id: "med-1" }) };
|
||||
|
||||
// A representative "yesterday" so the day is fully in the past and the
|
||||
// medication's createdAt clearly precedes it. The route walks 90 days
|
||||
// back from `new Date()`; this instant lands well inside that window.
|
||||
const DAY_OFFSET = 1;
|
||||
function dayBoundsForOffset(offset: number) {
|
||||
const now = new Date();
|
||||
const representative = new Date(
|
||||
now.getTime() - offset * 24 * 60 * 60 * 1000 - 12 * 60 * 60 * 1000,
|
||||
);
|
||||
const { start, end } = getUserTodayBounds(representative, TZ);
|
||||
return { start, endInclusive: end };
|
||||
}
|
||||
|
||||
// Build a UTC instant for a given "HH:mm" anchored on the day-start date,
|
||||
// mirroring how `classifyIntakeTiming` interprets the window via
|
||||
// `setUTCHours` on the scheduled date. Keeps the fixture's takenAt aligned
|
||||
// with the classifier's reference frame regardless of host timezone.
|
||||
function atSlotUTC(dayStart: Date, hhmm: string): Date {
|
||||
const [h, m] = hhmm.split(":").map(Number);
|
||||
const d = new Date(dayStart);
|
||||
d.setUTCHours(h, m, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
function medication(schedules: unknown[]) {
|
||||
return {
|
||||
id: "med-1",
|
||||
userId: "user-1",
|
||||
createdAt: new Date(Date.now() - 200 * 24 * 60 * 60 * 1000),
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
schedules,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
});
|
||||
|
||||
async function callRoute(): Promise<Record<string, unknown>> {
|
||||
const res = await GET(new Request("http://localhost"), ROUTE_PARAMS);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
return body.data.dailyCompliance as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe("GET /api/medications/[id]/compliance — per-slot timing", () => {
|
||||
it("classifies both doses of a single-row twice-daily schedule as on_time (green cell)", async () => {
|
||||
const { start: dayStart } = dayBoundsForOffset(DAY_OFFSET);
|
||||
const dateKey = userDayKey(dayStart, TZ);
|
||||
|
||||
// ONE schedule row, two times-of-day, a 1h window per slot.
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
|
||||
medication([
|
||||
{
|
||||
id: "sched-1",
|
||||
windowStart: "07:00",
|
||||
windowEnd: "08:00",
|
||||
timesOfDay: ["07:00", "19:00"],
|
||||
daysOfWeek: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
reminderGraceMinutes: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
},
|
||||
]) as never,
|
||||
);
|
||||
|
||||
// Both doses logged right at their slot times.
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
|
||||
{
|
||||
takenAt: atSlotUTC(dayStart, "07:05"),
|
||||
skipped: false,
|
||||
scheduledFor: atSlotUTC(dayStart, "07:00"),
|
||||
},
|
||||
{
|
||||
takenAt: atSlotUTC(dayStart, "19:05"),
|
||||
skipped: false,
|
||||
scheduledFor: atSlotUTC(dayStart, "19:00"),
|
||||
},
|
||||
] as never);
|
||||
|
||||
const daily = await callRoute();
|
||||
const entry = daily[dateKey] as Record<string, number> | undefined;
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry!.veryLate).toBe(0);
|
||||
expect(entry!.late).toBe(0);
|
||||
// Both doses compliant → green cell.
|
||||
expect(entry!.onTime).toBe(2);
|
||||
});
|
||||
|
||||
it("keeps single-time schedules byte-identical: a 12h-late dose is still very_late", async () => {
|
||||
const { start: dayStart } = dayBoundsForOffset(DAY_OFFSET);
|
||||
const dateKey = userDayKey(dayStart, TZ);
|
||||
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
|
||||
medication([
|
||||
{
|
||||
id: "sched-1",
|
||||
windowStart: "07:00",
|
||||
windowEnd: "08:00",
|
||||
timesOfDay: [], // single-time: candidate = [windowStart]
|
||||
daysOfWeek: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
reminderGraceMinutes: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
},
|
||||
]) as never,
|
||||
);
|
||||
|
||||
// Single 07:00 schedule, dose taken at 19:00 → ~12h late → very_late.
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
|
||||
{
|
||||
takenAt: atSlotUTC(dayStart, "19:00"),
|
||||
skipped: false,
|
||||
scheduledFor: atSlotUTC(dayStart, "07:00"),
|
||||
},
|
||||
] as never);
|
||||
|
||||
const daily = await callRoute();
|
||||
const entry = daily[dateKey] as Record<string, number> | undefined;
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry!.veryLate).toBe(1);
|
||||
expect(entry!.onTime).toBe(0);
|
||||
expect(entry!.late).toBe(0);
|
||||
});
|
||||
|
||||
it("handles an overnight per-slot window (23:00 slot, 1h span) as on_time near the slot", async () => {
|
||||
const { start: dayStart } = dayBoundsForOffset(DAY_OFFSET);
|
||||
const dateKey = userDayKey(dayStart, TZ);
|
||||
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
|
||||
medication([
|
||||
{
|
||||
id: "sched-1",
|
||||
windowStart: "23:00",
|
||||
windowEnd: "00:00", // overnight: end <= start
|
||||
timesOfDay: ["09:00", "23:00"],
|
||||
daysOfWeek: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
reminderGraceMinutes: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
},
|
||||
]) as never,
|
||||
);
|
||||
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
|
||||
{
|
||||
takenAt: atSlotUTC(dayStart, "23:10"),
|
||||
skipped: false,
|
||||
scheduledFor: atSlotUTC(dayStart, "23:00"),
|
||||
},
|
||||
] as never);
|
||||
|
||||
const daily = await callRoute();
|
||||
const entry = daily[dateKey] as Record<string, number> | undefined;
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry!.veryLate).toBe(0);
|
||||
expect(entry!.late).toBe(0);
|
||||
expect(entry!.onTime).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,74 @@ import { userDayKey } from "@/lib/tz/format";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
/** A single effective dose slot with its derived punctuality window. */
|
||||
interface SlotWindow {
|
||||
/** Minute-of-day of the slot's start time (used for nearest-slot match). */
|
||||
startMinutes: number;
|
||||
/** "HH:mm" slot start — fed to `classifyIntakeTiming` as `windowStart`. */
|
||||
windowStart: string;
|
||||
/** "HH:mm" slot end = slot start + the schedule's window span. */
|
||||
windowEnd: string;
|
||||
}
|
||||
|
||||
const MINUTES_PER_DAY = 24 * 60;
|
||||
|
||||
/** Parse "HH:mm" into minute-of-day. */
|
||||
function hhmmToMinutes(time: string): number {
|
||||
const [h, m] = time.split(":").map(Number);
|
||||
return h * 60 + m;
|
||||
}
|
||||
|
||||
/** Format a minute-of-day (wrapped into 0..1439) back to "HH:mm". */
|
||||
function minutesToHHmm(minutes: number): string {
|
||||
const wrapped = ((minutes % MINUTES_PER_DAY) + MINUTES_PER_DAY) %
|
||||
MINUTES_PER_DAY;
|
||||
const h = Math.floor(wrapped / 60);
|
||||
const m = wrapped % 60;
|
||||
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.8.1 B15 follow-on — expand every schedule into its effective dose
|
||||
* slots so timing classification matches against the right time-of-day.
|
||||
*
|
||||
* Candidate slot times for a schedule are its `timesOfDay` when non-empty,
|
||||
* else `[windowStart]` (so single-time schedules collapse to one slot at
|
||||
* `windowStart` and stay byte-identical to the pre-fix matcher). Each
|
||||
* slot's punctuality window is anchored at the slot time and spans the
|
||||
* schedule's `windowEnd − windowStart` duration; overnight windows
|
||||
* (`windowEnd ≤ windowStart`) add a day to the span before deriving the
|
||||
* per-slot end. Multi-schedule meds union their slots — the matcher then
|
||||
* picks the single closest slot across all of them.
|
||||
*/
|
||||
function buildSlotCandidates(
|
||||
schedules: { windowStart: string; windowEnd: string; timesOfDay: string[] }[],
|
||||
): SlotWindow[] {
|
||||
const slots: SlotWindow[] = [];
|
||||
for (const sched of schedules) {
|
||||
const startMin = hhmmToMinutes(sched.windowStart);
|
||||
let endMin = hhmmToMinutes(sched.windowEnd);
|
||||
// Overnight window: the end is on the following day.
|
||||
if (endMin <= startMin) endMin += MINUTES_PER_DAY;
|
||||
const spanMinutes = endMin - startMin;
|
||||
|
||||
const times =
|
||||
sched.timesOfDay.length > 0 ? sched.timesOfDay : [sched.windowStart];
|
||||
|
||||
for (const time of times) {
|
||||
const slotStartMin = hhmmToMinutes(time);
|
||||
slots.push({
|
||||
startMinutes: slotStartMin,
|
||||
windowStart: minutesToHHmm(slotStartMin),
|
||||
// `classifyIntakeTiming` re-detects the overnight wrap from the
|
||||
// HH:mm pair, so a wrapped end formats back into 0..1439 cleanly.
|
||||
windowEnd: minutesToHHmm(slotStartMin + spanMinutes),
|
||||
});
|
||||
}
|
||||
}
|
||||
return slots;
|
||||
}
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (_request: Request, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -115,33 +183,49 @@ export const GET = apiHandler(
|
||||
let veryLate = 0;
|
||||
let early = 0;
|
||||
|
||||
// v1.8.1 B15 follow-on — match each taken event to the closest
|
||||
// effective time-of-day SLOT, not merely `windowStart`. A single
|
||||
// schedule row carrying `timesOfDay = ["07:00","19:00"]` is two
|
||||
// distinct dose slots; matching the evening dose against the lone
|
||||
// `windowStart` "07:00" mis-classified it as `very_late` (~12h late)
|
||||
// and painted the heatmap cell orange even when both doses were
|
||||
// taken on time. Candidate slots for a schedule are its `timesOfDay`
|
||||
// when non-empty, else `[windowStart]` — so single-time schedules
|
||||
// collapse to exactly one candidate at `windowStart` and stay
|
||||
// byte-identical to the pre-fix behaviour. The per-slot window
|
||||
// mirrors the projector / cadence anchor: effective windowStart is
|
||||
// the slot time, effective windowEnd is the slot time plus the
|
||||
// schedule's `windowEnd − windowStart` span (overnight spans where
|
||||
// `windowEnd ≤ windowStart` add a day).
|
||||
const slotCandidates = buildSlotCandidates(medication.schedules);
|
||||
|
||||
for (const evt of takenEvents) {
|
||||
if (medication.schedules.length === 0) {
|
||||
if (slotCandidates.length === 0) {
|
||||
// No schedule info: treat all taken as on_time
|
||||
onTime++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match event to the closest schedule window by scheduledFor time
|
||||
// Match event to the closest slot window by scheduledFor time.
|
||||
const evtHour = evt.scheduledFor.getUTCHours();
|
||||
const evtMin = evt.scheduledFor.getUTCMinutes();
|
||||
const evtMinutes = evtHour * 60 + evtMin;
|
||||
|
||||
let bestSchedule = medication.schedules[0];
|
||||
let bestSlot = slotCandidates[0];
|
||||
let bestDist = Infinity;
|
||||
|
||||
for (const sched of medication.schedules) {
|
||||
const [sh, sm] = sched.windowStart.split(":").map(Number);
|
||||
const dist = Math.abs(evtHour * 60 + evtMin - (sh * 60 + sm));
|
||||
for (const slot of slotCandidates) {
|
||||
const dist = Math.abs(evtMinutes - slot.startMinutes);
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
bestSchedule = sched;
|
||||
bestSlot = slot;
|
||||
}
|
||||
}
|
||||
|
||||
const timing = classifyIntakeTiming(
|
||||
evt.takenAt,
|
||||
bestSchedule.windowStart,
|
||||
bestSchedule.windowEnd,
|
||||
bestSlot.windowStart,
|
||||
bestSlot.windowEnd,
|
||||
dayStart, // the scheduled date
|
||||
);
|
||||
|
||||
|
||||
@@ -26,6 +26,10 @@ vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
medication: {
|
||||
findUnique: vi.fn(),
|
||||
// v1.8.2 — the slot-snap resolver loads the medication via
|
||||
// findFirst; default to a schedule-less med so the resolver returns
|
||||
// null (unscheduled path) and the original insert behaviour holds.
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
@@ -34,6 +38,8 @@ vi.mock("@/lib/db", () => ({
|
||||
findFirst: vi.fn(),
|
||||
count: vi.fn(),
|
||||
create: vi.fn(),
|
||||
// v1.8.2 reconcile — the shared slot upsert updates in place.
|
||||
update: vi.fn(),
|
||||
},
|
||||
auditLog: { create: vi.fn() },
|
||||
$transaction: vi.fn(),
|
||||
@@ -307,6 +313,16 @@ describe("POST /api/medications/[id]/intake — one-shot lifecycle", () => {
|
||||
skipped: false,
|
||||
};
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue([createdEvent] as never);
|
||||
// v1.8.2 — slot-snap resolver loads the med; no schedules → null slot
|
||||
// → original insert + dedup path preserved.
|
||||
vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce({
|
||||
id: "med-1",
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: true,
|
||||
createdAt: new Date(),
|
||||
schedules: [],
|
||||
} as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValueOnce(
|
||||
null as never, // dedup probe
|
||||
);
|
||||
@@ -336,3 +352,184 @@ describe("POST /api/medications/[id]/intake — one-shot lifecycle", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// v1.8.2 reconcile — slot-snap upsert invariants through the route
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("POST /api/medications/[id]/intake — v1.8.2 reconcile (M2 inventory)", () => {
|
||||
function postReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/medications/med-1/intake", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
// A scheduled med (07:00 / 19:00) so the resolver returns a canonical
|
||||
// slot and the write routes through the shared upsert.
|
||||
const SCHEDULED_MED = {
|
||||
id: "med-1",
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
schedules: [
|
||||
{
|
||||
id: "s1",
|
||||
windowStart: "07:00",
|
||||
windowEnd: "07:00",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: ["07:00", "19:00"],
|
||||
reminderGraceMinutes: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("does NOT decrement inventory on a re-post of an already-taken slot (M2)", async () => {
|
||||
const { consumeOneDose } = await import(
|
||||
"@/lib/medications/inventory/service"
|
||||
);
|
||||
// Resolver load → scheduled med.
|
||||
vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce(
|
||||
SCHEDULED_MED as never,
|
||||
);
|
||||
// Slot find (shared upsert) → an existing TAKEN row at the 07:00 slot.
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
|
||||
{
|
||||
id: "row-taken",
|
||||
takenAt: new Date("2026-06-15T05:01:00Z"),
|
||||
skipped: false,
|
||||
idempotencyKey: null,
|
||||
scheduledFor: new Date("2026-06-15T05:00:00Z"),
|
||||
source: "WEB",
|
||||
createdAt: new Date("2026-06-15T05:01:00Z"),
|
||||
},
|
||||
] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValueOnce({
|
||||
id: "row-taken",
|
||||
takenAt: new Date("2026-06-15T05:02:00Z"),
|
||||
skipped: false,
|
||||
scheduledFor: new Date("2026-06-15T05:00:00Z"),
|
||||
source: "WEB",
|
||||
} as never);
|
||||
vi.mocked(prisma.medication.update).mockResolvedValue({} as never);
|
||||
// Reconcile probes (non-one-shot → noop).
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
oneShot: false,
|
||||
active: true,
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
postReq({
|
||||
scheduledFor: "2026-06-15T05:00:30.000Z",
|
||||
takenAt: "2026-06-15T05:02:00.000Z",
|
||||
}),
|
||||
ROUTE_PARAMS,
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
// Re-post of an already-taken slot → no pending→taken transition →
|
||||
// inventory must NOT be consumed.
|
||||
expect(consumeOneDose).not.toHaveBeenCalled();
|
||||
// The row was updated, not duplicated.
|
||||
expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("DOES decrement inventory on a genuine pending→taken move (M2)", async () => {
|
||||
const { consumeOneDose } = await import(
|
||||
"@/lib/medications/inventory/service"
|
||||
);
|
||||
vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce(
|
||||
SCHEDULED_MED as never,
|
||||
);
|
||||
// Slot find → a PENDING REMINDER row (no takenAt).
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
|
||||
{
|
||||
id: "row-pending",
|
||||
takenAt: null,
|
||||
skipped: false,
|
||||
idempotencyKey: null,
|
||||
scheduledFor: new Date("2026-06-15T05:00:00Z"),
|
||||
source: "REMINDER",
|
||||
createdAt: new Date("2026-06-15T00:00:00Z"),
|
||||
},
|
||||
] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValueOnce({
|
||||
id: "row-pending",
|
||||
takenAt: new Date("2026-06-15T05:02:00Z"),
|
||||
skipped: false,
|
||||
scheduledFor: new Date("2026-06-15T05:00:00Z"),
|
||||
source: "REMINDER",
|
||||
} as never);
|
||||
vi.mocked(prisma.medication.update).mockResolvedValue({} as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
oneShot: false,
|
||||
active: true,
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
postReq({
|
||||
scheduledFor: "2026-06-15T05:00:30.000Z",
|
||||
takenAt: "2026-06-15T05:02:00.000Z",
|
||||
}),
|
||||
ROUTE_PARAMS,
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
expect(consumeOneDose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("re-finds and updates the slot when the create races a P2002 (C1)", async () => {
|
||||
vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce(
|
||||
SCHEDULED_MED as never,
|
||||
);
|
||||
// First slot find → empty. Create throws P2002. Re-find → the racing
|
||||
// pending row. Update applies the taken write onto it.
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany)
|
||||
.mockResolvedValueOnce([] as never)
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: "row-raced",
|
||||
takenAt: null,
|
||||
skipped: false,
|
||||
idempotencyKey: null,
|
||||
scheduledFor: new Date("2026-06-15T05:00:00Z"),
|
||||
source: "REMINDER",
|
||||
createdAt: new Date("2026-06-15T00:00:00Z"),
|
||||
},
|
||||
] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.create).mockRejectedValueOnce(
|
||||
Object.assign(new Error("unique"), { code: "P2002" }),
|
||||
);
|
||||
vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValueOnce({
|
||||
id: "row-raced",
|
||||
takenAt: new Date("2026-06-15T05:02:00Z"),
|
||||
skipped: false,
|
||||
scheduledFor: new Date("2026-06-15T05:00:00Z"),
|
||||
source: "REMINDER",
|
||||
} as never);
|
||||
vi.mocked(prisma.medication.update).mockResolvedValue({} as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
oneShot: false,
|
||||
active: true,
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
postReq({
|
||||
scheduledFor: "2026-06-15T05:00:30.000Z",
|
||||
takenAt: "2026-06-15T05:02:00.000Z",
|
||||
}),
|
||||
ROUTE_PARAMS,
|
||||
);
|
||||
// The dose tap is NOT dropped — converges to the racing row, 201.
|
||||
expect(res.status).toBe(201);
|
||||
expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: "row-raced" } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,10 @@ import { reconcileOneShotState } from "@/lib/medications/lifecycle";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
|
||||
import {
|
||||
applyCanonicalSlotWrite,
|
||||
resolveSlotInstantForWrite,
|
||||
} from "@/lib/medications/scheduling/slot-upsert";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
@@ -74,6 +78,38 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
const { scheduledFor, takenAt, skipped, idempotencyKey } = parsed.data;
|
||||
|
||||
const resolvedTakenAt = skipped ? null : (takenAt ?? new Date());
|
||||
const incomingScheduledFor = scheduledFor ?? takenAt ?? new Date();
|
||||
// C2 — the per-med route only ever carries an explicit user gesture:
|
||||
// `resolvedTakenAt` is `now()` for any non-skip POST (there is no
|
||||
// "mark pending" write on this route), so a non-skip is an explicit
|
||||
// taken and a `skipped:true` body is an explicit skip. Neither is a
|
||||
// pending projection echo, so the no-downgrade guard never trips here —
|
||||
// it is the bulk/sync route that replays pending echoes. The flags are
|
||||
// threaded through so the shared upsert applies last-write-wins.
|
||||
const isExplicitTaken = !skipped;
|
||||
const isExplicitSkip = skipped === true;
|
||||
|
||||
// v1.8.2 — source-agnostic slot snap. A twice-daily med carries a
|
||||
// pending REMINDER row the projector/worker minted at the canonical
|
||||
// `localHmAsUtc` slot instant. Without this, a manual "Genommen" write
|
||||
// (source WEB) inserted a SECOND row for the slot because the unique
|
||||
// key includes `source` and the iOS-vs-server `scheduledFor` can drift
|
||||
// by a minute — inflating compliance to 100% and suppressing the
|
||||
// "take now" prompt for a dose the user hadn't taken. Snap the write
|
||||
// to the canonical slot and update the existing row in place.
|
||||
//
|
||||
// Resolved BEFORE the idempotency/dedup window so a scheduled dose
|
||||
// routes through the slot upsert (which is itself the dedup): the
|
||||
// legacy 60-second window would otherwise short-circuit by returning
|
||||
// the slot's pending REMINDER row WITHOUT applying the user's takenAt.
|
||||
const canonicalSlot = await resolveSlotInstantForWrite({
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
userTz: user.timezone,
|
||||
incoming: incomingScheduledFor,
|
||||
});
|
||||
|
||||
// Idempotency check (explicit key or server-side dedup window)
|
||||
if (idempotencyKey) {
|
||||
const existing = await prisma.medicationIntakeEvent.findFirst({
|
||||
@@ -86,8 +122,10 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
if (existing) {
|
||||
return apiSuccess(existing);
|
||||
}
|
||||
} else {
|
||||
// Server-side dedup: prevent double-logging within 60 seconds
|
||||
} else if (!canonicalSlot) {
|
||||
// Unscheduled / PRN only — the slot upsert handles dedup for
|
||||
// scheduled doses by collapsing onto the canonical slot row.
|
||||
// Server-side dedup: prevent double-logging within 60 seconds.
|
||||
const recentDuplicate = await prisma.medicationIntakeEvent.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
@@ -102,28 +140,65 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
}
|
||||
}
|
||||
|
||||
const [event] = await prisma.$transaction([
|
||||
prisma.medicationIntakeEvent.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
scheduledFor: scheduledFor ?? takenAt ?? new Date(),
|
||||
takenAt: skipped ? null : (takenAt ?? new Date()),
|
||||
skipped,
|
||||
source: "WEB",
|
||||
idempotencyKey: idempotencyKey ?? null,
|
||||
},
|
||||
}),
|
||||
// Reset snooze when medication is taken
|
||||
...(!skipped
|
||||
? [
|
||||
prisma.medication.update({
|
||||
where: { id },
|
||||
data: { snoozedUntil: null },
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
let event;
|
||||
// v1.8.2 reconcile — whether this write moved the slot pending→taken.
|
||||
// Only that transition decrements pen inventory (M2). For the
|
||||
// unscheduled/PRN branch a non-skip write always records a fresh dose,
|
||||
// so it consumes when not skipped.
|
||||
let consumedTransition = !skipped;
|
||||
if (canonicalSlot) {
|
||||
// Scheduled dose — converge onto the one canonical slot row regardless
|
||||
// of `source` (the pending REMINDER row, or any prior row for this
|
||||
// slot) through the shared upsert: H1 deterministic selection, C2
|
||||
// no-downgrade guard, and a C1 race-safe create that re-finds + updates
|
||||
// on a P2002 collision rather than 500-ing or duplicating.
|
||||
const applied = await applyCanonicalSlotWrite({
|
||||
client: prisma,
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
canonicalSlot,
|
||||
takenAt: resolvedTakenAt,
|
||||
skipped,
|
||||
isExplicitTaken,
|
||||
isExplicitSkip,
|
||||
idempotencyKey: idempotencyKey ?? null,
|
||||
createSource: "WEB",
|
||||
});
|
||||
event = applied.row;
|
||||
consumedTransition = applied.consumedTransition;
|
||||
// Reset the snooze when a dose is actually recorded (not on a
|
||||
// no-downgrade no-op, which left the prior taken row untouched).
|
||||
if (!skipped && !applied.noDowngradeNoOp) {
|
||||
await prisma.medication.update({
|
||||
where: { id },
|
||||
data: { snoozedUntil: null },
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Unscheduled / PRN / off-slot — keep the original insert behaviour.
|
||||
[event] = await prisma.$transaction([
|
||||
prisma.medicationIntakeEvent.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
scheduledFor: incomingScheduledFor,
|
||||
takenAt: resolvedTakenAt,
|
||||
skipped,
|
||||
source: "WEB",
|
||||
idempotencyKey: idempotencyKey ?? null,
|
||||
},
|
||||
}),
|
||||
// Reset snooze when medication is taken
|
||||
...(!skipped
|
||||
? [
|
||||
prisma.medication.update({
|
||||
where: { id },
|
||||
data: { snoozedUntil: null },
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
}
|
||||
|
||||
// v1.4.25 W19b — pen-inventory dose decrement. Only fires for
|
||||
// non-skipped intakes; a skipped event is not a consumption event.
|
||||
@@ -131,8 +206,14 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
// meds). Failures here must never block the intake write, so
|
||||
// errors are swallowed and logged — the intake is the source of
|
||||
// truth, the inventory is an opt-in companion.
|
||||
//
|
||||
// v1.8.2 M2 — gate on an ACTUAL pending→taken transition. An idempotent
|
||||
// re-post of an already-taken slot updates the row in place but must NOT
|
||||
// decrement again, else a repeated iOS sync drifts the GLP-1 pen count
|
||||
// down on every replay. `consumedTransition` is false when the slot was
|
||||
// already taken (or on a no-downgrade no-op).
|
||||
let inventoryOutcome: Awaited<ReturnType<typeof consumeOneDose>> = null;
|
||||
if (!skipped) {
|
||||
if (!skipped && consumedTransition) {
|
||||
try {
|
||||
inventoryOutcome = await consumeOneDose({
|
||||
userId: user.id,
|
||||
|
||||
@@ -9,10 +9,15 @@ vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
medication: {
|
||||
findMany: vi.fn(),
|
||||
// v1.8.2 — the slot resolver loads the med via findFirst.
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
medicationIntakeEvent: {
|
||||
create: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
// v1.8.2 reconcile — shared slot upsert reads + updates in place.
|
||||
findMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
@@ -151,3 +156,164 @@ describe("POST /api/medications/intake/bulk — 422 multi-issue (v1.4.43 W6)", (
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// v1.8.2 reconcile — bulk slot-snap upsert invariants (C2 + C1)
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("POST /api/medications/intake/bulk — v1.8.2 reconcile", () => {
|
||||
const SCHEDULED_MED = {
|
||||
id: "med-1",
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
schedules: [
|
||||
{
|
||||
id: "s1",
|
||||
windowStart: "07:00",
|
||||
windowEnd: "07:00",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: ["07:00", "19:00"],
|
||||
reminderGraceMinutes: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
{ id: "med-1" },
|
||||
] as never);
|
||||
vi.mocked(prisma.medication.findFirst).mockResolvedValue(
|
||||
SCHEDULED_MED as never,
|
||||
);
|
||||
});
|
||||
|
||||
it("C2 — a pending echo onto an already-TAKEN slot is reported duplicate, NOT a downgrade", async () => {
|
||||
// Existing TAKEN row at the 07:00 slot. The bulk entry is a pending
|
||||
// echo (no takenAt, skipped false) — must NOT clear takenAt.
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
|
||||
{
|
||||
id: "row-taken",
|
||||
takenAt: new Date("2026-06-15T05:01:00Z"),
|
||||
skipped: false,
|
||||
idempotencyKey: null,
|
||||
scheduledFor: new Date("2026-06-15T05:00:00Z"),
|
||||
source: "WEB",
|
||||
createdAt: new Date("2026-06-15T05:01:00Z"),
|
||||
},
|
||||
] as never);
|
||||
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{
|
||||
medicationId: "med-1",
|
||||
scheduledFor: "2026-06-15T05:00:30.000Z",
|
||||
// no takenAt, skipped defaults false → pending echo
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
duplicates: number;
|
||||
updated: number;
|
||||
entries: Array<{ status: string; id?: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data.duplicates).toBe(1);
|
||||
expect(body.data.updated).toBe(0);
|
||||
expect(body.data.entries[0].status).toBe("duplicate");
|
||||
// The recorded dose was NEVER touched — no update issued.
|
||||
expect(prisma.medicationIntakeEvent.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("C2 — an explicit takenAt still applies onto a pending slot (status updated)", async () => {
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
|
||||
{
|
||||
id: "row-pending",
|
||||
takenAt: null,
|
||||
skipped: false,
|
||||
idempotencyKey: null,
|
||||
scheduledFor: new Date("2026-06-15T05:00:00Z"),
|
||||
source: "REMINDER",
|
||||
createdAt: new Date("2026-06-15T00:00:00Z"),
|
||||
},
|
||||
] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValueOnce({
|
||||
id: "row-pending",
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{
|
||||
medicationId: "med-1",
|
||||
scheduledFor: "2026-06-15T05:00:30.000Z",
|
||||
takenAt: "2026-06-15T05:02:00.000Z",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { updated: number; entries: Array<{ status: string }> };
|
||||
};
|
||||
expect(body.data.updated).toBe(1);
|
||||
expect(body.data.entries[0].status).toBe("updated");
|
||||
});
|
||||
|
||||
it("C1 — a same-slot P2002 on create converges via re-find+update, not dropped as duplicate", async () => {
|
||||
// First slot find empty → create races a P2002 → re-find returns the
|
||||
// racing pending row → update applies the incoming takenAt.
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany)
|
||||
.mockResolvedValueOnce([] as never)
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: "row-raced",
|
||||
takenAt: null,
|
||||
skipped: false,
|
||||
idempotencyKey: null,
|
||||
scheduledFor: new Date("2026-06-15T05:00:00Z"),
|
||||
source: "REMINDER",
|
||||
createdAt: new Date("2026-06-15T00:00:00Z"),
|
||||
},
|
||||
] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.create).mockRejectedValueOnce(
|
||||
Object.assign(new Error("unique"), { code: "P2002" }),
|
||||
);
|
||||
vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValueOnce({
|
||||
id: "row-raced",
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{
|
||||
medicationId: "med-1",
|
||||
scheduledFor: "2026-06-15T05:00:30.000Z",
|
||||
takenAt: "2026-06-15T05:02:00.000Z",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { updated: number; entries: Array<{ status: string; id?: string }> };
|
||||
};
|
||||
// The dose is APPLIED to the existing row, not silently dropped.
|
||||
expect(body.data.updated).toBe(1);
|
||||
expect(body.data.entries[0].status).toBe("updated");
|
||||
expect(body.data.entries[0].id).toBe("row-raced");
|
||||
expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: "row-raced" } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -47,6 +47,10 @@ import {
|
||||
recomputeMedicationComplianceForDay,
|
||||
dayKeyForScheduledFor,
|
||||
} from "@/lib/rollups/medication-compliance-rollups";
|
||||
import {
|
||||
applyCanonicalSlotWrite,
|
||||
resolveSlotInstantForWrite,
|
||||
} from "@/lib/medications/scheduling/slot-upsert";
|
||||
|
||||
const MAX_ENTRIES_PER_BATCH = 500;
|
||||
const BATCH_RATE_LIMIT_MAX = 60;
|
||||
@@ -70,7 +74,12 @@ const bulkPayloadSchema = z.object({
|
||||
entries: z.array(bulkEntrySchema).min(1).max(MAX_ENTRIES_PER_BATCH),
|
||||
});
|
||||
|
||||
type EntryStatus = "inserted" | "duplicate" | "skipped";
|
||||
// v1.8.2 — `updated` joins the per-entry status vocabulary: a write that
|
||||
// snaps onto an existing scheduled-slot row (e.g. the pending REMINDER
|
||||
// row) updates it in place rather than inserting. The iOS sync engine
|
||||
// treats `updated` the same as `inserted` for cursor advancement — both
|
||||
// mean "the server accepted this entry and produced a row id".
|
||||
type EntryStatus = "inserted" | "updated" | "duplicate" | "skipped";
|
||||
interface EntryResult {
|
||||
index: number;
|
||||
status: EntryStatus;
|
||||
@@ -154,6 +163,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
|
||||
const results: EntryResult[] = [];
|
||||
let inserted = 0;
|
||||
let updated = 0;
|
||||
let duplicates = 0;
|
||||
const skipped: Array<{ index: number; reason: string }> = [];
|
||||
// v1.4.39 W-MED — collect distinct `(medicationId, dayKey)` pairs
|
||||
@@ -173,31 +183,93 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
}
|
||||
|
||||
try {
|
||||
const scheduledFor = entry.scheduledFor ?? new Date();
|
||||
// The idempotencyKey, when supplied, has a UNIQUE index. A
|
||||
// re-submission of the same key returns the existing row via
|
||||
// P2002 → status: "duplicate".
|
||||
const row = await prisma.medicationIntakeEvent.create({
|
||||
data: {
|
||||
const incomingScheduledFor =
|
||||
entry.scheduledFor ?? entry.takenAt ?? new Date();
|
||||
|
||||
// v1.8.2 — source-agnostic slot snap. iOS posts the "Genommen"
|
||||
// reminder action here (source API); without this it inserted a
|
||||
// SECOND row for a slot already carrying the projector/worker's
|
||||
// pending REMINDER row (the unique key includes `source`, and the
|
||||
// iOS-vs-server `scheduledFor` drifts by a minute). Snap onto the
|
||||
// canonical slot instant and update the existing row in place.
|
||||
const canonicalSlot = await resolveSlotInstantForWrite({
|
||||
userId: user.id,
|
||||
medicationId: entry.medicationId,
|
||||
userTz: user.timezone,
|
||||
incoming: incomingScheduledFor,
|
||||
});
|
||||
|
||||
const scheduledFor = canonicalSlot ?? incomingScheduledFor;
|
||||
|
||||
// C2 — classify the incoming write. An offline-sync replay echoes a
|
||||
// PENDING projection (no `takenAt`, `skipped:false`) for a slot the
|
||||
// user may already have actioned; that echo must NEVER clear a
|
||||
// recorded `takenAt`. An explicit `takenAt` or `skipped:true` is a
|
||||
// real user action and applies last-write-wins.
|
||||
const isExplicitTaken = !entry.skipped && entry.takenAt !== undefined;
|
||||
const isExplicitSkip = entry.skipped === true;
|
||||
|
||||
if (canonicalSlot) {
|
||||
// Scheduled dose — converge onto the one canonical slot row through
|
||||
// the shared upsert: H1 deterministic selection, C2 no-downgrade
|
||||
// guard, and a C1 race-safe create that re-finds + updates on a
|
||||
// P2002 collision rather than misclassifying it as a duplicate and
|
||||
// dropping the dose.
|
||||
const applied = await applyCanonicalSlotWrite({
|
||||
client: prisma,
|
||||
userId: user.id,
|
||||
medicationId: entry.medicationId,
|
||||
scheduledFor,
|
||||
canonicalSlot,
|
||||
takenAt: entry.takenAt ?? null,
|
||||
skipped: entry.skipped,
|
||||
source: "API",
|
||||
isExplicitTaken,
|
||||
isExplicitSkip,
|
||||
idempotencyKey: entry.idempotencyKey ?? null,
|
||||
},
|
||||
});
|
||||
inserted += 1;
|
||||
results.push({ index: i, status: "inserted", id: row.id });
|
||||
createSource: "API",
|
||||
});
|
||||
if (applied.noDowngradeNoOp) {
|
||||
// C2 — pending echo onto an already-actioned slot. Report it as a
|
||||
// duplicate so the iOS cursor advances WITHOUT downgrading the
|
||||
// recorded dose.
|
||||
duplicates += 1;
|
||||
results.push({ index: i, status: "duplicate", id: applied.row.id });
|
||||
} else if (applied.outcome === "updated") {
|
||||
updated += 1;
|
||||
results.push({ index: i, status: "updated", id: applied.row.id });
|
||||
} else {
|
||||
inserted += 1;
|
||||
results.push({ index: i, status: "inserted", id: applied.row.id });
|
||||
}
|
||||
} else {
|
||||
// Unscheduled / PRN — insert. The idempotencyKey, when supplied,
|
||||
// has a UNIQUE index; a re-submission returns the existing row via
|
||||
// P2002 → status: "duplicate".
|
||||
const row = await prisma.medicationIntakeEvent.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
medicationId: entry.medicationId,
|
||||
scheduledFor,
|
||||
takenAt: entry.takenAt ?? null,
|
||||
skipped: entry.skipped,
|
||||
source: "API",
|
||||
idempotencyKey: entry.idempotencyKey ?? null,
|
||||
},
|
||||
});
|
||||
inserted += 1;
|
||||
results.push({ index: i, status: "inserted", id: row.id });
|
||||
}
|
||||
const dayKey = dayKeyForScheduledFor(scheduledFor, user.timezone);
|
||||
touchedDays.set(`${entry.medicationId}|${dayKey}`, {
|
||||
medicationId: entry.medicationId,
|
||||
dayKey,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
// P2002 = unique-constraint violation; the idempotencyKey
|
||||
// already exists → "duplicate", not an error.
|
||||
// P2002 = unique-constraint violation. Two shapes reach here:
|
||||
// 1. an idempotencyKey collision on the unscheduled/PRN insert →
|
||||
// "duplicate" (the canonical-slot path already absorbs its own
|
||||
// same-slot P2002 race inside `applyCanonicalSlotWrite`);
|
||||
// 2. a same-slot collision on the unscheduled insert. In both
|
||||
// cases we surface the existing row id so the cursor advances.
|
||||
if (
|
||||
typeof err === "object" &&
|
||||
err !== null &&
|
||||
@@ -231,6 +303,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
details: {
|
||||
processed: entries.length,
|
||||
inserted,
|
||||
updated,
|
||||
duplicates,
|
||||
skipped: skipped.length,
|
||||
},
|
||||
@@ -241,6 +314,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
meta: {
|
||||
processed: entries.length,
|
||||
inserted,
|
||||
updated,
|
||||
duplicates,
|
||||
skipped: skipped.length,
|
||||
},
|
||||
@@ -249,7 +323,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
// v1.4.34 IW-G — bust per-user medications + compliance + achievement
|
||||
// caches when at least one row landed so the next read reflects the
|
||||
// ingested batch.
|
||||
if (inserted > 0) {
|
||||
if (inserted > 0 || updated > 0) {
|
||||
invalidateUserMedications(user.id);
|
||||
}
|
||||
|
||||
@@ -282,6 +356,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
return apiSuccess({
|
||||
processed: entries.length,
|
||||
inserted,
|
||||
updated,
|
||||
duplicates,
|
||||
skipped,
|
||||
entries: results,
|
||||
|
||||
@@ -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" />}
|
||||
@@ -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" />}
|
||||
|
||||
+2
-1
@@ -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"
|
||||
+2
-1
@@ -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="%"
|
||||
+2
-1
@@ -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"
|
||||
+2
-1
@@ -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"
|
||||
@@ -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="%"
|
||||
+2
-1
@@ -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"
|
||||
+2
-1
@@ -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=""
|
||||
+2
-1
@@ -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"
|
||||
+2
-1
@@ -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"
|
||||
+2
-1
@@ -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" />}
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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]}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -97,7 +97,7 @@ function buildSelectionSections(s: SectionState) {
|
||||
}
|
||||
|
||||
export function HealthRecordExportPanel() {
|
||||
const { t } = useTranslations();
|
||||
const { t, locale } = useTranslations();
|
||||
const [format, setFormat] = useState<ExportFormat>("pdf");
|
||||
const [days, setDays] = useState<number>(90);
|
||||
const [practiceName, setPracticeName] = useState("");
|
||||
@@ -140,6 +140,10 @@ export function HealthRecordExportPanel() {
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
format,
|
||||
// Carry the active in-app locale so the generated artefact matches
|
||||
// the UI language instead of falling back to the browser's
|
||||
// Accept-Language header on the server.
|
||||
locale,
|
||||
range: { days },
|
||||
practiceName: practiceName.trim() || undefined,
|
||||
includeCharts,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { PDFParse } from "pdf-parse";
|
||||
import {
|
||||
buildDoctorReportPdfDocument,
|
||||
renderDoctorReportPdfBytes,
|
||||
sanitiseForPdf,
|
||||
DOCTOR_REPORT_VITAL_TYPES,
|
||||
DOCTOR_REPORT_TYPE_LABEL_KEYS,
|
||||
DOCTOR_REPORT_TYPE_UNIT_KEYS,
|
||||
@@ -183,6 +184,96 @@ describe("buildDoctorReportPdfDocument", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── WinAnsi sanitiser ── the rendered Helvetica is WinAnsi-encoded; glyphs
|
||||
// outside it (trend arrows, superscripts) resolve to the .notdef box at a
|
||||
// fallback advance width and stretch the surrounding line. The sanitiser
|
||||
// maps every offender the report can emit onto a WinAnsi-safe equivalent.
|
||||
describe("sanitiseForPdf", () => {
|
||||
it("maps trend arrows to ASCII equivalents", () => {
|
||||
expect(sanitiseForPdf("↑")).toBe("^");
|
||||
expect(sanitiseForPdf("↓")).toBe("v");
|
||||
expect(sanitiseForPdf("→")).toBe("->");
|
||||
});
|
||||
|
||||
it("maps the superscript-two in kg/m² to a plain 2", () => {
|
||||
expect(sanitiseForPdf("kg/m²")).toBe("kg/m2");
|
||||
});
|
||||
|
||||
it("leaves WinAnsi-safe glyphs (umlauts, ß, em-dash) untouched", () => {
|
||||
expect(sanitiseForPdf("Müller — Größe")).toBe("Müller — Größe");
|
||||
});
|
||||
|
||||
it("renders the GLP-1 weight summary without any non-WinAnsi glyph", async () => {
|
||||
const data = makeData({
|
||||
glp1: {
|
||||
weightStartKg: 92,
|
||||
weightEndKg: 86,
|
||||
weightDeltaKg: -6,
|
||||
medications: [],
|
||||
sideEffects: [],
|
||||
} as unknown as DoctorReportData["glp1"],
|
||||
});
|
||||
const bytes = renderDoctorReportPdfBytes(data, {
|
||||
t: getServerTranslator("de").t,
|
||||
locale: "de",
|
||||
now: FIXED_NOW,
|
||||
});
|
||||
const text = await extractText(bytes);
|
||||
// The arrow separator must have been folded to ASCII; no raw arrow in
|
||||
// the document text layer.
|
||||
expect(text).not.toMatch(/[↑↓→²]/);
|
||||
expect(text).toContain("->");
|
||||
});
|
||||
});
|
||||
|
||||
// ── chart time axis ── the sparkline must anchor the trend in real time so a
|
||||
// reader can tell whether the curve spans a week or a year.
|
||||
describe("doctor-report sparkline time axis", () => {
|
||||
it("prints the series start and end dates under the chart", async () => {
|
||||
const data = makeData({
|
||||
measurements: {
|
||||
WEIGHT: [
|
||||
{ value: 82, measuredAt: "2026-02-05T08:00:00.000Z" },
|
||||
{ value: 81, measuredAt: "2026-03-10T08:00:00.000Z" },
|
||||
{ value: 79.5, measuredAt: "2026-04-28T08:00:00.000Z" },
|
||||
],
|
||||
},
|
||||
});
|
||||
const bytes = renderDoctorReportPdfBytes(data, {
|
||||
t: getServerTranslator("de").t,
|
||||
locale: "de",
|
||||
includeCharts: true,
|
||||
now: FIXED_NOW,
|
||||
});
|
||||
const text = await extractText(bytes);
|
||||
// dd.mm short labels (Berlin tz). First + last sample dates.
|
||||
expect(text).toContain("05.02.");
|
||||
expect(text).toContain("28.04.");
|
||||
});
|
||||
});
|
||||
|
||||
// ── page-break discipline ── a long table must not orphan its heading or
|
||||
// collide with the footer; the renderer paginates without throwing.
|
||||
describe("doctor-report pagination", () => {
|
||||
it("paginates a long compliance table across pages without throwing", () => {
|
||||
const compliance: DoctorReportData["compliance"] = {};
|
||||
for (let i = 0; i < 60; i++) {
|
||||
compliance[`Medication ${i}`] = {
|
||||
total: 90,
|
||||
taken: 80,
|
||||
skipped: 5,
|
||||
missed: 5,
|
||||
};
|
||||
}
|
||||
const doc = buildDoctorReportPdfDocument(makeData({ compliance }), {
|
||||
t: getServerTranslator("de").t,
|
||||
locale: "de",
|
||||
now: FIXED_NOW,
|
||||
});
|
||||
expect(doc.getNumberOfPages()).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
// Audit-2026-05-07 / phase P0 / closes audit C-9: server-side PDF used a
|
||||
// stale type map and silently dropped body composition while the browser
|
||||
// PDF rendered them. These tests pin the contract.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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}`;
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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}`;
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
type ComplianceSchedule,
|
||||
} from "../compliance";
|
||||
import type { IntakeTimingClass } from "../compliance";
|
||||
import { buildCadenceTimeline } from "@/lib/medications/scheduling/cadence";
|
||||
import { getUserTodayBounds } from "@/lib/timezone";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
|
||||
@@ -124,6 +125,81 @@ describe("calculateCompliance — cadence-aware adapter", () => {
|
||||
expect(result.missed).toBe(3);
|
||||
});
|
||||
|
||||
it("daily 2×/day on ONE schedule row (timesOfDay), all taken → 100%", () => {
|
||||
// Production shape: the wizard stores a twice-daily med as a single
|
||||
// MedicationSchedule row carrying `timesOfDay = ["07:00","19:00"]`,
|
||||
// NOT two rows. Pre-fix the legacy cadence walker only emitted the
|
||||
// `windowStart` slot, so the denominator was one dose/day and a
|
||||
// perfectly-adherent user could read 50% once a second dose was
|
||||
// logged. Pin the single-row twice-daily case at 100%.
|
||||
const schedules: ComplianceSchedule[] = [
|
||||
{
|
||||
windowStart: "07:00",
|
||||
windowEnd: "07:30",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: ["07:00", "19:00"],
|
||||
},
|
||||
];
|
||||
// Log both doses for every day in (and just past) the 7-day window
|
||||
// so no emitted slot is left unpaired at the window boundary. NOW is
|
||||
// 12:00 UTC, so today's 19:00 slot is still `upcoming` (excluded).
|
||||
const events = [];
|
||||
for (let d = 0; d <= 8; d++) {
|
||||
const day = new Date(NOW);
|
||||
day.setUTCDate(day.getUTCDate() - d);
|
||||
const morning = new Date(day);
|
||||
morning.setUTCHours(7, 5, 0, 0);
|
||||
const evening = new Date(day);
|
||||
evening.setUTCHours(19, 5, 0, 0);
|
||||
events.push(eventAt(morning, true));
|
||||
if (d > 0) events.push(eventAt(evening, true));
|
||||
}
|
||||
|
||||
const result = calculateCompliance(events, schedules, 7);
|
||||
expect(result.rate).toBe(100);
|
||||
expect(result.missed).toBe(0);
|
||||
// The window covers ~7 days × 2 doses, so the denominator is well
|
||||
// above the 7-or-so a one-slot-per-day walker would produce. The
|
||||
// load-bearing assertion is that BOTH daily doses are counted (the
|
||||
// fan-out) and every one pairs to a taken event → 100%.
|
||||
expect(result.totalExpected).toBeGreaterThanOrEqual(12);
|
||||
expect(result.taken).toBe(result.totalExpected);
|
||||
});
|
||||
|
||||
it("2×/day, delete the evening dose then re-add it → still 100% / 2 of 2", () => {
|
||||
// The live regression: a user on a 07:00 + 19:00 schedule deletes
|
||||
// the evening intake and re-adds it. The compliance must read the
|
||||
// re-added dose dynamically — 2 expected, 2 taken — not collapse to
|
||||
// 50%. Modelled here on a single calendar day with both slots in the
|
||||
// past so neither is `upcoming`.
|
||||
const lateNow = new Date("2025-01-15T22:00:00Z");
|
||||
vi.setSystemTime(lateNow);
|
||||
const schedules: ComplianceSchedule[] = [
|
||||
{
|
||||
windowStart: "07:00",
|
||||
windowEnd: "07:30",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: ["07:00", "19:00"],
|
||||
},
|
||||
];
|
||||
// Morning taken once; the evening dose was deleted (its tombstoned
|
||||
// row is excluded from the events array by the route's
|
||||
// `deletedAt: null` filter) and re-added as a fresh taken event.
|
||||
const morning = new Date("2025-01-15T07:05:00Z");
|
||||
const eveningReAdded = new Date("2025-01-15T19:10:00Z");
|
||||
const events = [
|
||||
eventAt(morning, true),
|
||||
eventAt(eveningReAdded, true),
|
||||
];
|
||||
const result = calculateCompliance(events, schedules, 1, undefined, {
|
||||
now: lateNow,
|
||||
});
|
||||
expect(result.taken).toBe(2);
|
||||
expect(result.totalExpected).toBe(2);
|
||||
expect(result.missed).toBe(0);
|
||||
expect(result.rate).toBe(100);
|
||||
});
|
||||
|
||||
it("weekly Mondays, all Mondays taken in a 30-day window → 100% (the #214 bug case)", () => {
|
||||
// Pre-v1.5.0 this returned ~13% (taken / (schedules × 30) = 4/30).
|
||||
// The fix is to honour `daysOfWeek` so the denominator is the
|
||||
@@ -557,6 +633,39 @@ describe("calculateCompliance — engine-routed (medicationContext)", () => {
|
||||
expect(result.rate).toBe(100);
|
||||
expect(result.missed).toBe(0);
|
||||
});
|
||||
|
||||
it("B15: legacy daysOfWeek row with two timesOfDay — 2×/day taken reads 100%, not 50%", () => {
|
||||
// Regression for the compliance divergence: a plain `daysOfWeek`
|
||||
// schedule (no rrule / rolling, SCHEDULED, not one-shot) carrying two
|
||||
// `timesOfDay` must expand its numerator through the same engine as
|
||||
// the denominator. Before the fix the numerator's local legacy walker
|
||||
// emitted one slot/day from `windowStart`, while the denominator's
|
||||
// engine counted both — collapsing a fully-adherent 2×/day med to
|
||||
// 1/2 = 50%.
|
||||
const schedules: ComplianceSchedule[] = [
|
||||
{
|
||||
windowStart: "08:00",
|
||||
windowEnd: "19:30",
|
||||
daysOfWeek: null, // every day
|
||||
timesOfDay: ["08:00", "19:00"],
|
||||
// no rrule, no rollingIntervalDays, scheduleType defaults SCHEDULED
|
||||
},
|
||||
];
|
||||
// Pin a single-day window so exactly two slots are expected. NOW is
|
||||
// late evening UTC so both of today's slots are in the past.
|
||||
vi.setSystemTime(new Date("2025-06-09T23:30:00Z"));
|
||||
const events = [
|
||||
eventAt(new Date("2025-06-09T08:00:00Z"), true),
|
||||
eventAt(new Date("2025-06-09T19:00:00Z"), true),
|
||||
];
|
||||
const result = calculateCompliance(events, schedules, 1, undefined, {
|
||||
medicationContext: ctx({ startsOn: new Date("2025-05-01T00:00:00Z") }),
|
||||
});
|
||||
// Both daily slots expand and both pair with a taken intake → 2/2.
|
||||
expect(result.totalExpected).toBe(2);
|
||||
expect(result.taken).toBe(2);
|
||||
expect(result.rate).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
@@ -645,6 +754,62 @@ describe("expectedSlotCountForDay + lastNonSkippedTakenAt", () => {
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
|
||||
it("B15 invariant: legacy multi-time row — denominator and numerator expand identically", () => {
|
||||
// The B15 bug lived in the numerator's expander, not here — this
|
||||
// helper always ran the engine. Pin the convergence directly: for a
|
||||
// plain `daysOfWeek` row carrying two `timesOfDay` (no rrule), the
|
||||
// per-day denominator and the cadence numerator
|
||||
// (`buildCadenceTimeline`) must report the same slot count.
|
||||
const dayStart = new Date("2025-06-09T00:00:00Z");
|
||||
const dayEnd = new Date("2025-06-10T00:00:00Z");
|
||||
const schedules: ComplianceSchedule[] = [
|
||||
{
|
||||
windowStart: "08:00",
|
||||
windowEnd: "19:30",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: ["08:00", "19:00"],
|
||||
},
|
||||
];
|
||||
const medCtx = ctx({
|
||||
startsOn: new Date("2025-05-01T00:00:00Z"),
|
||||
timeZone: "Europe/Berlin",
|
||||
});
|
||||
|
||||
const denominator = expectedSlotCountForDay(
|
||||
schedules,
|
||||
dayStart,
|
||||
dayEnd,
|
||||
medCtx,
|
||||
);
|
||||
|
||||
const numerator = buildCadenceTimeline(
|
||||
[
|
||||
{
|
||||
windowStart: "08:00",
|
||||
windowEnd: "19:30",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: ["08:00", "19:00"],
|
||||
},
|
||||
],
|
||||
[],
|
||||
new Date("2025-06-09T23:59:00Z"),
|
||||
1,
|
||||
dayStart,
|
||||
medCtx.timeZone,
|
||||
{
|
||||
startsOn: medCtx.startsOn,
|
||||
endsOn: medCtx.endsOn,
|
||||
oneShot: medCtx.oneShot,
|
||||
createdAt: medCtx.createdAt,
|
||||
lastIntakeAt: medCtx.lastIntakeAt,
|
||||
timeZone: medCtx.timeZone,
|
||||
},
|
||||
).length;
|
||||
|
||||
expect(numerator).toBe(denominator);
|
||||
expect(numerator).toBe(2);
|
||||
});
|
||||
|
||||
it("lastNonSkippedTakenAt picks the newest non-skipped takenAt", () => {
|
||||
const events = [
|
||||
{ takenAt: new Date("2025-01-10T08:00:00Z"), skipped: false },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -18,6 +18,93 @@ import type { DoctorReportData } from "./doctor-report-data";
|
||||
|
||||
type T = (key: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
/**
|
||||
* jsPDF's built-in Helvetica is WinAnsi-encoded. Latin-1 glyphs (umlauts,
|
||||
* ß, em-/en-dash, typographic quotes, °, µ) carry correct metrics, but any
|
||||
* code point outside WinAnsi resolves to the `.notdef` box at a single
|
||||
* fallback advance width. The widths then disagree with the drawn glyph and
|
||||
* the surrounding words stretch / shift — the visible "stretched line" bug.
|
||||
*
|
||||
* This sanitiser maps every glyph the report can emit that falls outside
|
||||
* WinAnsi onto a WinAnsi-safe equivalent, applied centrally to every string
|
||||
* just before it reaches `doc.text` / `doc.splitTextToSize` (see
|
||||
* `patchPdfTextSanitiser`). The offenders that actually occur in the report
|
||||
* are the trend arrows (↑ ↓ →) injected by `trendArrow()` + the `glp1WeightSummary`
|
||||
* separator, and the superscript-two in "kg/m²". The mapping is exhaustive
|
||||
* for those and degrades gracefully for any future stray symbol.
|
||||
*
|
||||
* Premium follow-up (documented, not a hotfix blocker): embed a Unicode TTF
|
||||
* (e.g. DejaVuSans) via `doc.addFileToVFS` / `addFont` so the arrows and
|
||||
* superscripts render as their true glyphs instead of ASCII equivalents.
|
||||
*/
|
||||
const WINANSI_REPLACEMENTS: Record<string, string> = {
|
||||
// Trend arrows → ASCII so the metrics match the drawn glyph.
|
||||
"↑": "^", // ↑ up
|
||||
"↓": "v", // ↓ down
|
||||
"→": "->", // → right (also the glp1 weight separator)
|
||||
"←": "<-", // ←
|
||||
"↔": "<->", // ↔
|
||||
// Super-/subscripts used in "kg/m²".
|
||||
"²": "2", // ²
|
||||
"³": "3", // ³
|
||||
"¹": "1", // ¹
|
||||
// Defensive: a few maths/symbol glyphs that are outside WinAnsi but read
|
||||
// fine as ASCII, in case a future string introduces them.
|
||||
"≈": "~", // ≈
|
||||
"≤": "<=", // ≤
|
||||
"≥": ">=", // ≥
|
||||
"×": "x", // ×
|
||||
"€": "EUR", // €
|
||||
};
|
||||
|
||||
const WINANSI_REPLACE_RE = new RegExp(
|
||||
`[${Object.keys(WINANSI_REPLACEMENTS).join("")}]`,
|
||||
"g",
|
||||
);
|
||||
|
||||
/** Map non-WinAnsi glyphs onto safe equivalents. Pure; exported for tests. */
|
||||
export function sanitiseForPdf(text: string): string {
|
||||
return text.replace(WINANSI_REPLACE_RE, (ch) => WINANSI_REPLACEMENTS[ch] ?? ch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch `doc.text` + `doc.splitTextToSize` on a single jsPDF instance so
|
||||
* every drawn string (direct text, wrapped paragraphs, AND `jspdf-autotable`
|
||||
* cells — which route their content through `doc.text` too) passes through
|
||||
* `sanitiseForPdf` first. One choke point instead of a sanitiser call at
|
||||
* every site keeps the renderer readable and guarantees coverage.
|
||||
*/
|
||||
function patchPdfTextSanitiser(doc: jsPDF): void {
|
||||
const clean = (value: unknown): unknown =>
|
||||
typeof value === "string"
|
||||
? sanitiseForPdf(value)
|
||||
: Array.isArray(value)
|
||||
? value.map((v) => (typeof v === "string" ? sanitiseForPdf(v) : v))
|
||||
: value;
|
||||
|
||||
const originalText = doc.text.bind(doc);
|
||||
doc.text = function patchedText(
|
||||
this: jsPDF,
|
||||
...args: Parameters<jsPDF["text"]>
|
||||
): jsPDF {
|
||||
const next = [...args] as unknown[];
|
||||
next[0] = clean(next[0]);
|
||||
return originalText(...(next as Parameters<jsPDF["text"]>));
|
||||
} as jsPDF["text"];
|
||||
|
||||
const originalSplit = doc.splitTextToSize.bind(doc);
|
||||
doc.splitTextToSize = function patchedSplit(
|
||||
this: jsPDF,
|
||||
...args: Parameters<jsPDF["splitTextToSize"]>
|
||||
): ReturnType<jsPDF["splitTextToSize"]> {
|
||||
const next = [...args] as unknown[];
|
||||
next[0] = clean(next[0]);
|
||||
return originalSplit(
|
||||
...(next as Parameters<jsPDF["splitTextToSize"]>),
|
||||
);
|
||||
} as jsPDF["splitTextToSize"];
|
||||
}
|
||||
|
||||
export interface DoctorReportRenderOptions {
|
||||
t: T;
|
||||
locale: Locale;
|
||||
@@ -234,10 +321,34 @@ function buildClinicalSummaryLines(
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** A single charted reading. */
|
||||
type SparklinePoint = { value: number; measuredAt: string };
|
||||
|
||||
/** Label band (5 mm) + plot (16 mm) + axis-date line (4.5 mm) + trailing gap. */
|
||||
const SPARKLINE_HEIGHT = 5 + 16 + 4.5 + 2;
|
||||
|
||||
/**
|
||||
* Draw a jsPDF-native trend sparkline (label + min/max ticks + polyline).
|
||||
* Returns the new `y` cursor below the drawn chart. Vector-only — uses
|
||||
* `doc.lines()`, no raster image, no native canvas module.
|
||||
* Rough autoTable height estimate (mm) used by the page-break guards so a
|
||||
* heading + its table do not orphan. The grid theme at fontSize 9 +
|
||||
* cellPadding 3 renders header + each body row at ~9 mm. Deliberately a
|
||||
* conservative over-estimate — a slightly early break is harmless, a missed
|
||||
* one tears the module.
|
||||
*/
|
||||
function estimateTableHeight(rowCount: number): number {
|
||||
const HEADER_MM = 9;
|
||||
const ROW_MM = 9;
|
||||
return HEADER_MM + rowCount * ROW_MM;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a jsPDF-native trend sparkline (label + min/max ticks + polyline +
|
||||
* a time axis). Returns the new `y` cursor below the drawn chart. Vector-only
|
||||
* — uses `doc.lines()`, no raster image, no native canvas module.
|
||||
*
|
||||
* Each point carries its `measuredAt` so the x-axis is anchored in real time:
|
||||
* points are positioned by their timestamp across the report window, and the
|
||||
* first/last dates are printed under the baseline. Without the axis a reader
|
||||
* cannot tell whether the trend spans a week or a year.
|
||||
*/
|
||||
function drawSparkline(
|
||||
doc: jsPDF,
|
||||
@@ -246,20 +357,23 @@ function drawSparkline(
|
||||
y: number;
|
||||
width: number;
|
||||
label: string;
|
||||
values: number[];
|
||||
points: SparklinePoint[];
|
||||
num: FormatNum;
|
||||
unit: string;
|
||||
dateShort: (iso: string) => string;
|
||||
},
|
||||
): number {
|
||||
const { x, y, width, label, values, num, unit } = opts;
|
||||
const { x, y, width, label, points, num, unit, dateShort } = opts;
|
||||
const chartHeight = 16;
|
||||
const labelHeight = 5;
|
||||
const axisLabelHeight = 4.5;
|
||||
|
||||
doc.setFontSize(9);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(30, 30, 30);
|
||||
doc.text(label, x, y + labelHeight - 1.5);
|
||||
|
||||
const values = points.map((p) => p.value);
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
const range = max - min || 1;
|
||||
@@ -275,20 +389,36 @@ function drawSparkline(
|
||||
doc.setLineWidth(0.2);
|
||||
doc.line(x, chartBottom, x + chartWidth, chartBottom);
|
||||
|
||||
// Polyline points → jsPDF relative `lines()` deltas.
|
||||
const stepX = values.length > 1 ? chartWidth / (values.length - 1) : 0;
|
||||
const points = values.map((v, i) => ({
|
||||
px: x + i * stepX,
|
||||
py: chartBottom - ((v - min) / range) * chartHeight,
|
||||
}));
|
||||
// X position is anchored in time: map each reading's timestamp across the
|
||||
// [first, last] span. When every timestamp collapses to one instant fall
|
||||
// back to even spacing so a same-day cluster still plots.
|
||||
const times = points.map((p) => new Date(p.measuredAt).getTime());
|
||||
const tMin = Math.min(...times);
|
||||
const tMax = Math.max(...times);
|
||||
const tSpan = tMax - tMin;
|
||||
const plotted = points.map((p, i) => {
|
||||
const frac =
|
||||
tSpan > 0
|
||||
? (times[i] - tMin) / tSpan
|
||||
: points.length > 1
|
||||
? i / (points.length - 1)
|
||||
: 0;
|
||||
return {
|
||||
px: x + frac * chartWidth,
|
||||
py: chartBottom - ((p.value - min) / range) * chartHeight,
|
||||
};
|
||||
});
|
||||
doc.setDrawColor(80, 110, 200);
|
||||
doc.setLineWidth(0.4);
|
||||
const deltas: [number, number][] = [];
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
deltas.push([points[i].px - points[i - 1].px, points[i].py - points[i - 1].py]);
|
||||
for (let i = 1; i < plotted.length; i++) {
|
||||
deltas.push([
|
||||
plotted[i].px - plotted[i - 1].px,
|
||||
plotted[i].py - plotted[i - 1].py,
|
||||
]);
|
||||
}
|
||||
if (deltas.length > 0) {
|
||||
doc.lines(deltas, points[0].px, points[0].py);
|
||||
doc.lines(deltas, plotted[0].px, plotted[0].py);
|
||||
}
|
||||
|
||||
// Min/max labels in the right gutter.
|
||||
@@ -299,7 +429,17 @@ function drawSparkline(
|
||||
doc.text(`${num(max, 1)}${unitSuffix}`, x + chartWidth + 2, chartTop + 2);
|
||||
doc.text(`${num(min, 1)}${unitSuffix}`, x + chartWidth + 2, chartBottom);
|
||||
|
||||
return chartBottom + 2;
|
||||
// Time axis: first date left-aligned, last date right-aligned under the
|
||||
// baseline so the trend's span is unambiguous.
|
||||
const axisY = chartBottom + axisLabelHeight - 1;
|
||||
const startLabel = dateShort(points[0]!.measuredAt);
|
||||
const endLabel = dateShort(points[points.length - 1]!.measuredAt);
|
||||
doc.text(startLabel, x, axisY);
|
||||
if (endLabel !== startLabel) {
|
||||
doc.text(endLabel, x + chartWidth, axisY, { align: "right" });
|
||||
}
|
||||
|
||||
return chartBottom + axisLabelHeight + 2;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -336,6 +476,9 @@ export function buildDoctorReportPdfDocument(
|
||||
};
|
||||
|
||||
const doc = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" });
|
||||
// Route every drawn string through the WinAnsi sanitiser so non-WinAnsi
|
||||
// glyphs (trend arrows, superscripts) can never stretch a line.
|
||||
patchPdfTextSanitiser(doc);
|
||||
// v1.7.0 — document metadata (PDF/A-leaning). jsPDF embeds the standard
|
||||
// Helvetica fonts and pulls no external resources; setting the document
|
||||
// properties + a deterministic creation date gives most practice systems
|
||||
@@ -348,9 +491,33 @@ export function buildDoctorReportPdfDocument(
|
||||
author: data.patient.fullName ?? data.patient.username ?? "HealthLog",
|
||||
});
|
||||
const pageWidth = doc.internal.pageSize.getWidth();
|
||||
const pageHeight = doc.internal.pageSize.getHeight();
|
||||
const margin = 20;
|
||||
// Bottom safe area: the three-line footer disclaimer sits in the last
|
||||
// ~14 mm; reserve it plus a small breathing gap so body content never
|
||||
// collides with the footer or runs off the page. `contentMaxY` is the
|
||||
// single source of truth every break check + autoTable bottom margin
|
||||
// refers to — no more scattered `y > 240` magic numbers.
|
||||
const FOOTER_HEIGHT = 16;
|
||||
const bottomMargin = 6;
|
||||
const contentMaxY = pageHeight - bottomMargin - FOOTER_HEIGHT;
|
||||
const tableBottomMargin = bottomMargin + FOOTER_HEIGHT;
|
||||
let y = margin;
|
||||
|
||||
/**
|
||||
* Page-break guard. Adds a page and resets the cursor to the top margin
|
||||
* when the upcoming block (height `needed`, in mm) would not fit above
|
||||
* `contentMaxY`. Call BEFORE drawing a module heading so a heading never
|
||||
* gets orphaned at the bottom of a page. Returns the (possibly reset) y.
|
||||
*/
|
||||
const ensureSpace = (current: number, needed: number): number => {
|
||||
if (current + needed > contentMaxY) {
|
||||
doc.addPage();
|
||||
return margin;
|
||||
}
|
||||
return current;
|
||||
};
|
||||
|
||||
doc.setFontSize(22);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(30, 30, 30);
|
||||
@@ -449,6 +616,9 @@ export function buildDoctorReportPdfDocument(
|
||||
// physician a one-paragraph orientation before the detail tables.
|
||||
const summaryLines = buildClinicalSummaryLines(data, t, num);
|
||||
if (summaryLines.length > 0) {
|
||||
// Keep the heading with at least its first line (heading 5 mm + one body
|
||||
// line 4.5 mm + trailing gap).
|
||||
y = ensureSpace(y, 5 + 4.5 + 4);
|
||||
doc.setFontSize(12);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(30, 30, 30);
|
||||
@@ -460,6 +630,10 @@ export function buildDoctorReportPdfDocument(
|
||||
for (const line of summaryLines) {
|
||||
const wrapped = doc.splitTextToSize(line, pageWidth - 2 * margin);
|
||||
for (const w of wrapped) {
|
||||
if (y + 4.5 > contentMaxY) {
|
||||
doc.addPage();
|
||||
y = margin;
|
||||
}
|
||||
doc.text(w, margin, y);
|
||||
y += 4.5;
|
||||
}
|
||||
@@ -467,6 +641,8 @@ export function buildDoctorReportPdfDocument(
|
||||
y += 4;
|
||||
}
|
||||
|
||||
// Vitals heading — keep it with the start of its table.
|
||||
y = ensureSpace(y, 6 + 18);
|
||||
doc.setFontSize(14);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(30, 30, 30);
|
||||
@@ -535,7 +711,12 @@ export function buildDoctorReportPdfDocument(
|
||||
fontStyle: "bold",
|
||||
},
|
||||
alternateRowStyles: { fillColor: [252, 252, 252] },
|
||||
margin: { left: margin, right: margin },
|
||||
margin: {
|
||||
left: margin,
|
||||
right: margin,
|
||||
top: margin,
|
||||
bottom: tableBottomMargin,
|
||||
},
|
||||
});
|
||||
y =
|
||||
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
|
||||
@@ -550,10 +731,8 @@ export function buildDoctorReportPdfDocument(
|
||||
(type) => (data.measurements[type]?.length ?? 0) >= 2,
|
||||
);
|
||||
if (chartTypes.length > 0) {
|
||||
if (y > 235) {
|
||||
doc.addPage();
|
||||
y = margin;
|
||||
}
|
||||
// Keep the heading with at least the first chart.
|
||||
y = ensureSpace(y, 6 + SPARKLINE_HEIGHT + 4);
|
||||
doc.setFontSize(12);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(30, 30, 30);
|
||||
@@ -561,19 +740,19 @@ export function buildDoctorReportPdfDocument(
|
||||
y += 6;
|
||||
for (const type of chartTypes) {
|
||||
const series = data.measurements[type] ?? [];
|
||||
if (y > 250) {
|
||||
doc.addPage();
|
||||
y = margin;
|
||||
}
|
||||
// A chart never tears across a page boundary — break before drawing
|
||||
// it whole.
|
||||
y = ensureSpace(y, SPARKLINE_HEIGHT + 4);
|
||||
const label = t(DOCTOR_REPORT_TYPE_LABEL_KEYS[type] ?? "");
|
||||
y = drawSparkline(doc, {
|
||||
x: margin,
|
||||
y,
|
||||
width: pageWidth - 2 * margin,
|
||||
label,
|
||||
values: series.map((p) => p.value),
|
||||
points: series,
|
||||
num,
|
||||
unit: unitFor(type),
|
||||
dateShort: (iso) => formatters.dateShort(iso),
|
||||
});
|
||||
y += 4;
|
||||
}
|
||||
@@ -584,6 +763,7 @@ export function buildDoctorReportPdfDocument(
|
||||
const sysStat = data.stats.BLOOD_PRESSURE_SYS;
|
||||
const diaStat = data.stats.BLOOD_PRESSURE_DIA;
|
||||
if (sysStat && diaStat) {
|
||||
y = ensureSpace(y, 5 + 8);
|
||||
doc.setFontSize(12);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.text(t("doctorReport.bpClassificationTitle"), margin, y);
|
||||
@@ -601,6 +781,7 @@ export function buildDoctorReportPdfDocument(
|
||||
}
|
||||
|
||||
if (data.bmi) {
|
||||
y = ensureSpace(y, 5 + 8);
|
||||
doc.setFontSize(12);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.text(t("doctorReport.bmiTitle"), margin, y);
|
||||
@@ -624,10 +805,7 @@ export function buildDoctorReportPdfDocument(
|
||||
(ctx) => data.glucoseStats?.[ctx],
|
||||
);
|
||||
if (loggedGlucose.length > 0) {
|
||||
if (y > 240) {
|
||||
doc.addPage();
|
||||
y = margin;
|
||||
}
|
||||
y = ensureSpace(y, 5 + 5 + 3);
|
||||
doc.setFontSize(12);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.text(t("doctorReport.glucoseClassificationTitle"), margin, y);
|
||||
@@ -645,6 +823,10 @@ export function buildDoctorReportPdfDocument(
|
||||
: s.avg < range.min
|
||||
? "doctorReport.glucoseBelowTarget"
|
||||
: "doctorReport.glucoseAboveTarget";
|
||||
if (y + 5 > contentMaxY) {
|
||||
doc.addPage();
|
||||
y = margin;
|
||||
}
|
||||
doc.text(
|
||||
t("doctorReport.glucoseRow", {
|
||||
label: t(GLUCOSE_LABEL_KEYS[ctx]),
|
||||
@@ -664,10 +846,9 @@ export function buildDoctorReportPdfDocument(
|
||||
|
||||
const complianceEntries = Object.entries(data.compliance);
|
||||
if (complianceEntries.length > 0) {
|
||||
if (y > 240) {
|
||||
doc.addPage();
|
||||
y = margin;
|
||||
}
|
||||
// Keep the heading with the table header + first row (~6 mm heading +
|
||||
// ~9 mm header + ~9 mm row).
|
||||
y = ensureSpace(y, 6 + estimateTableHeight(Math.min(complianceEntries.length, 1)));
|
||||
|
||||
doc.setFontSize(14);
|
||||
doc.setFont("helvetica", "bold");
|
||||
@@ -713,7 +894,12 @@ export function buildDoctorReportPdfDocument(
|
||||
fontStyle: "bold",
|
||||
},
|
||||
alternateRowStyles: { fillColor: [252, 252, 252] },
|
||||
margin: { left: margin, right: margin },
|
||||
margin: {
|
||||
left: margin,
|
||||
right: margin,
|
||||
top: margin,
|
||||
bottom: tableBottomMargin,
|
||||
},
|
||||
});
|
||||
y =
|
||||
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
|
||||
@@ -726,10 +912,8 @@ export function buildDoctorReportPdfDocument(
|
||||
// current drug + dose, full titration history, weight curve over
|
||||
// the report window, side-effect frequency, and compliance %.
|
||||
if (data.glp1) {
|
||||
if (y > 220) {
|
||||
doc.addPage();
|
||||
y = margin;
|
||||
}
|
||||
// Keep the GLP-1 heading with its first content line.
|
||||
y = ensureSpace(y, 6 + 6 + 5);
|
||||
|
||||
doc.setFontSize(14);
|
||||
doc.setFont("helvetica", "bold");
|
||||
@@ -756,10 +940,8 @@ export function buildDoctorReportPdfDocument(
|
||||
}
|
||||
|
||||
for (const med of data.glp1.medications) {
|
||||
if (y > 240) {
|
||||
doc.addPage();
|
||||
y = margin;
|
||||
}
|
||||
// Keep the med name with at least its first detail line.
|
||||
y = ensureSpace(y, 5 + 5);
|
||||
doc.setFontSize(11);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.text(med.name, margin, y);
|
||||
@@ -797,6 +979,9 @@ export function buildDoctorReportPdfDocument(
|
||||
`${num(dc.value, 2)} ${dc.unit}`,
|
||||
dc.note ?? "",
|
||||
]);
|
||||
// A titration history reads as one block — break before it if the
|
||||
// whole table would not fit, and tell autoTable not to split a row.
|
||||
y = ensureSpace(y, estimateTableHeight(historyRows.length));
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
head: [
|
||||
@@ -808,6 +993,7 @@ export function buildDoctorReportPdfDocument(
|
||||
],
|
||||
body: historyRows,
|
||||
theme: "grid",
|
||||
rowPageBreak: "avoid",
|
||||
styles: {
|
||||
fontSize: 9,
|
||||
cellPadding: 3,
|
||||
@@ -821,7 +1007,12 @@ export function buildDoctorReportPdfDocument(
|
||||
fontStyle: "bold",
|
||||
},
|
||||
alternateRowStyles: { fillColor: [252, 252, 252] },
|
||||
margin: { left: margin, right: margin },
|
||||
margin: {
|
||||
left: margin,
|
||||
right: margin,
|
||||
top: margin,
|
||||
bottom: tableBottomMargin,
|
||||
},
|
||||
});
|
||||
y =
|
||||
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
|
||||
@@ -830,15 +1021,14 @@ export function buildDoctorReportPdfDocument(
|
||||
}
|
||||
|
||||
if (data.glp1.sideEffects.length > 0) {
|
||||
if (y > 240) {
|
||||
doc.addPage();
|
||||
y = margin;
|
||||
}
|
||||
const seRows = data.glp1.sideEffects.map((s) => [s.tag, String(s.count)]);
|
||||
// Keep the heading with the table; the side-effect tally reads as one
|
||||
// block.
|
||||
y = ensureSpace(y, 5 + estimateTableHeight(seRows.length));
|
||||
doc.setFontSize(11);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.text(t("doctorReport.glp1SideEffectsTitle"), margin, y);
|
||||
y += 5;
|
||||
const seRows = data.glp1.sideEffects.map((s) => [s.tag, String(s.count)]);
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
head: [
|
||||
@@ -846,6 +1036,7 @@ export function buildDoctorReportPdfDocument(
|
||||
],
|
||||
body: seRows,
|
||||
theme: "grid",
|
||||
rowPageBreak: "avoid",
|
||||
styles: {
|
||||
fontSize: 9,
|
||||
cellPadding: 3,
|
||||
@@ -859,7 +1050,12 @@ export function buildDoctorReportPdfDocument(
|
||||
fontStyle: "bold",
|
||||
},
|
||||
alternateRowStyles: { fillColor: [252, 252, 252] },
|
||||
margin: { left: margin, right: margin },
|
||||
margin: {
|
||||
left: margin,
|
||||
right: margin,
|
||||
top: margin,
|
||||
bottom: tableBottomMargin,
|
||||
},
|
||||
});
|
||||
y =
|
||||
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
|
||||
@@ -868,10 +1064,10 @@ export function buildDoctorReportPdfDocument(
|
||||
}
|
||||
|
||||
if (data.mood) {
|
||||
if (y > 240) {
|
||||
doc.addPage();
|
||||
y = margin;
|
||||
}
|
||||
const moodRowCount = Object.keys(data.mood.distribution).length;
|
||||
// Keep the heading + summary line with the start of the distribution
|
||||
// table.
|
||||
y = ensureSpace(y, 6 + 6 + estimateTableHeight(Math.min(moodRowCount, 1)));
|
||||
|
||||
doc.setFontSize(14);
|
||||
doc.setFont("helvetica", "bold");
|
||||
@@ -925,7 +1121,12 @@ export function buildDoctorReportPdfDocument(
|
||||
textColor: [30, 30, 30],
|
||||
fontStyle: "bold",
|
||||
},
|
||||
margin: { left: margin, right: margin },
|
||||
margin: {
|
||||
left: margin,
|
||||
right: margin,
|
||||
top: margin,
|
||||
bottom: tableBottomMargin,
|
||||
},
|
||||
});
|
||||
y =
|
||||
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
|
||||
@@ -938,10 +1139,8 @@ export function buildDoctorReportPdfDocument(
|
||||
// for a machine-generated diagnosis.
|
||||
const aiText = typeof aiSummary === "string" ? aiSummary.trim() : "";
|
||||
if (aiText.length > 0) {
|
||||
if (y > 220) {
|
||||
doc.addPage();
|
||||
y = margin;
|
||||
}
|
||||
// Keep the heading + the first disclaimer line together.
|
||||
y = ensureSpace(y, 5 + 4 + 4.5);
|
||||
doc.setFontSize(12);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(120, 80, 0);
|
||||
@@ -955,6 +1154,10 @@ export function buildDoctorReportPdfDocument(
|
||||
pageWidth - 2 * margin,
|
||||
);
|
||||
for (const line of disclaimer) {
|
||||
if (y + 4 > contentMaxY) {
|
||||
doc.addPage();
|
||||
y = margin;
|
||||
}
|
||||
doc.text(line, margin, y);
|
||||
y += 4;
|
||||
}
|
||||
@@ -964,7 +1167,7 @@ export function buildDoctorReportPdfDocument(
|
||||
doc.setTextColor(60, 60, 60);
|
||||
const wrapped = doc.splitTextToSize(aiText, pageWidth - 2 * margin);
|
||||
for (const line of wrapped) {
|
||||
if (y > 270) {
|
||||
if (y + 4.5 > contentMaxY) {
|
||||
doc.addPage();
|
||||
y = margin;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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" });
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Loaded 100 of 145 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user