chore(release): v1.7.0

v1.7.0 — health-record export (PDF + FHIR), flexible medication schedules, first-paint dashboard snapshot, full HealthKit coverage, offline sync delta feed, Coach data clustering. See CHANGELOG.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 authored and GitHub committed 2026-05-31 14:36:17 +02:00
1 parent 33b912babd
commit 50e0a65228
237 files changed
+22322 -1963

No files matched your search

+40
View File
@@ -1,5 +1,45 @@
# Changelog
## [1.7.0] — 2026-05-31 — Health-record export, flexible schedules, first-paint snapshot, full HealthKit coverage
A broad polishing release across the medication, dashboard, export, and HealthKit surfaces. Medications gain PRN (as-needed) and cyclic on/off-week schedules, a server-computed next-due instant, and cadence-canonical compliance so non-daily plans report adherence correctly everywhere — including the detail page, which previously still expanded through the legacy weekday walker. The dashboard now assembles its above-the-fold tiles in a single snapshot round-trip and pre-generates the daily briefing overnight, so a cold load no longer staggers tile-by-tile and never blocks on the model. The doctor handover grows into a selectable health-record export — an enriched PDF and a machine-readable HL7 FHIR R4 bundle, packaged together. Every previously-unplotted HealthKit metric gets a chart, walking speed reads in km/h, and the Coach can be fed any chosen cluster of data instead of a fixed five. The medication detail page restores its history view and gathers every setting into one redesigned advanced sheet.
### Added
- **PRN and cyclic schedules** — a medication schedule can be marked as-needed (`asNeeded`), excluded from the expected-dose count and reminder projection while staying loggable, or cyclic (`cycleWeeksOn` / `cycleWeeksOff` / `cycleAnchor`) for on/off-week dosing. Both are projected by the canonical recurrence engine. Migration `0092` adds the schedule-type enum and cyclic columns.
- **Server-computed next-due instant** — every medication carries a read-only `nextDueAt` (RFC-5545 + rolling math, `null` for PRN), so a client never has to duplicate the recurrence engine.
- **Schedule-aware compliance payload** — the per-day compliance bucket gains `due` / `expectedCount`, so a history view can skip empty marks on non-due days for weekly, bi-weekly, rolling, and cyclic plans.
- **Per-medication reminder flags** — `liveActivityEnabled` and `criticalAlarmEnabled` (both default off) ride the medication contract for clients that surface lock-screen or break-through reminders. A roaming user-level delivery default plus a per-device override let a device honour or locally override the default. Migration `0091`.
- **Health-record export** — a new `POST /api/export/health-record` produces a selectable export: an enriched clinical PDF (patient identity, deterministic summary, native sparklines), an HL7 FHIR R4 document bundle (LOINC-coded `Observation`s, a BP panel, `MedicationStatement`s, a `DiagnosticReport`), or both packaged as one zip. The selection chooses date range and per-domain sections, and the AI summary is an explicit opt-in section clearly marked as not clinically validated. The bundle reuses the same aggregator the PDF consumes, so both describe identical numbers, and matches the iOS client's LOINC/UCUM conventions for interchangeable records.
- **Patient identity on the profile** — optional full name, insurer, and insurance number on Account, for the report cover and the FHIR `Patient`. The insurance number is validated (KVNR mod-10) and encrypted at rest.
- **Unified dashboard snapshot** — `GET /api/dashboard/snapshot` assembles the above-the-fold tile data in one round-trip from the rollup tier plus the cached briefing, so the tiles arrive together. A coverage miss returns the heavy slice as deferred rather than blocking the strip. The snapshot also carries a `metricStates` seed (latest value/timestamp/unit per metric) and the full widget-layout catalogue, so a native client can paint a cold launch from one cached request.
- **Nightly insight pre-generation** — a budget-gated pg-boss job warms the comprehensive insight (and its daily briefing) overnight, so the `/insights` mount is a cache read instead of a synchronous model call. A cache miss now shows a preparing state rather than blocking the request.
- **Charts for every stored HealthKit metric** — the previously-unplotted types (flights climbed, environmental and headphone audio exposure, walking speed, step length, asymmetry, double support, respiratory rate, body-composition family, mobility, daylight, and more) each gain a chart surface. Walking speed displays in km/h and distance in km via a render-time transform; stored and exported values stay canonical SI.
- **Display-unit preference** — a metric/imperial toggle in Settings → Display (migration `0094`), read by the chart display transforms.
- **Coach data clustering** — the Coach accepts a chosen set of data clusters (cardiovascular, body composition, activity, workouts, sleep, mood, glucose, medication, mobility, environment) instead of a fixed set, with a soft budget cap that degrades the lowest-signal clusters first so enabling everything stays within the prompt budget. The default reproduces the prior domains.
- **Offline sync delta feed** — `GET /api/sync/changes` exposes a paginated, opaque-cursor delta feed with tombstones across three domains — measurements, mood entries, and medication intakes — over a single multi-domain cursor; `/api/sync/state` is now part of the documented API and reports the sync window. Measurement, mood, and intake delete routes soft-delete (tombstone) rather than hard-delete, pruned past a retention window keyed to the refresh-token lifetime. A stable `errorCode` on the refresh endpoint distinguishes a revoked family, a token reuse, and a transient failure. Migrations `0096` + `0097` add the keyset indexes and the mood/intake sync columns.
- **Configurable mood-reminder time** — the evening mood-reminder hour is now per-user (it was a fixed 22:00), stored in the roaming notification preferences so a native client and the web share one source of truth.
### Changed
- **Cadence-canonical compliance everywhere** — `calculateCompliance` and the medication detail page route expected-dose computation through the canonical recurrence engine, so `rrule`, `rollingIntervalDays`, weekday subsets, interval weeks, one-shot, and PRN all count correctly. The dashboard tile and the detail page no longer disagree for non-daily plans.
- **Medication detail surface** — the header offers a direct edit, a history view (the clock-with-counterclockwise-arrow icon), and an advanced-settings button. The history view is intake-only, sorted newest-first, with the estimated active-ingredient curve as an opt-in disclosure. The advanced sheet is widened and regrouped into Data, Reminders, Lifecycle, and a danger zone, with CSV/JSON import surfaced as a real action.
- **Dashboard widget layout round-trips the full catalogue** — saving a layout no longer rejects ids the web surface doesn't render; the full widget catalogue (web tiles + client-only tiles) is persisted and returned intact, so a native client keeps its own tiles without a local merge step. A genuinely unknown id is still ignored.
- **Keyboard navigation for segmented controls** — the metric/imperial, export-format, and mood-rating radiogroups gain roving-tabindex arrow-key navigation (Home/End, wrap), via one shared hook.
- **Dashboard charts refresh on an open page** — the snapshot and analytics queries poll on a shared interval so an open dashboard picks up new measurements without a manual reload, served from a warm per-user cache.
- **Profile photo preserves its aspect ratio** — the avatar renders with `object-cover` instead of stretching.
### Fixed
- **The mood-reminder card now delivers on its own** — enabling the reminder previously still required a buried, default-off per-event push preference, so a user who flipped the visible card received nothing; the card alone now drives delivery (an explicit per-event opt-out still suppresses).
- **Multi-time-of-day reminders no longer re-fire** — the reminder ledger delete is scoped to the single dispatched slot rather than wiping every row for a medication, and logged doses are matched to slots by time rather than array order.
- **Soft-deleted measurements stay deleted on direct access** — the single-resource GET and PUT now filter tombstoned rows.
- **Mobile settings navigation no longer auto-scrolls the page** — the section strip positions its active chip horizontally without nudging the document, removing the dizzy scroll on sub-page navigation.
### Tests
- 5628 → 5878 unit; 1 skipped. New coverage spans the recurrence-engine PRN/cyclic cases, cadence-canonical compliance parity per cadence type, the FHIR bundle shape and LOINC/UCUM mapping, the KVNR validator, the export route, the dashboard snapshot envelope and pre-generation cron, the measurement delta feed and soft-delete tombstones, the daily-mean consolidation drain, and the display-unit transforms. A six-reviewer audit (correctness, security, architecture, design/a11y, simplification, i18n) ran before release; every High and Medium finding was resolved.
## [1.6.0] — 2026-05-30 — Medication editor overhaul, route of administration, today-tile read-flip
v1.5.6 settled the medication detail page into a pure history view, but the create/edit plan still funnelled every dose through the same flat shape, the injection-site picker only surfaced for GLP-1, and the today-tile on the dashboard and the Erfassen sheet still expanded daily schedules through a legacy walker that silently skipped bi-weekly, rolling, RRULE, and one-time cadences. This release reworks the medication editor and its detail surface, gives route of administration a first-class column so any injection can carry a site, adds a one-time-injection shape, and flips the today-tile onto the canonical recurrence engine the reminder worker already uses — so what the tile shows matches what the worker mints. It also restores the multi-language request snippets on the API-tokens row, adds a profile-photo upload to Account, and finishes an admin-surface polish pass.
+12 -9
View File
@@ -38,7 +38,7 @@
HealthLog is a self-hosted personal health tracker that runs from a single `docker compose up`. It covers the metrics most people actually log -- weight, blood pressure, pulse, body composition, blood glucose, sleep, mood, and medication compliance -- and brings them together in one dashboard with reference ranges from ESH 2023, ADA 2024, and NICE NG115. Withings devices sync automatically; an `export.zip` import folds your full Apple Health history into the same timeline; a native SwiftUI iOS client (public-beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa)) streams HealthKit live; multi-provider AI Insights (BYOK or local) explain what the numbers mean; a doctor-report PDF generates client-side. EN/DE end-to-end. AGPL-3.0.
> **Status**: active. New releases roughly weekly -- see [CHANGELOG](CHANGELOG.md). Current line: v1.5 — native iOS client now public-beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa).
> **Status**: active. New releases roughly weekly -- see [CHANGELOG](CHANGELOG.md). Current line: v1.7 — health-record export (PDF + FHIR R4), flexible medication schedules, full HealthKit metric coverage, and a first-paint dashboard snapshot. The native iOS client is public-beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa).
> **Heavily developed.** HealthLog ships multiple releases per week. Behaviour, API shapes, and database schema can change between minor versions; migrations are forward-only and not all are rehearsed against every legacy fixture. If you self-host, pin a tag, take a backup before every upgrade, and read the [CHANGELOG](CHANGELOG.md) before pulling `latest`. Issue reports and PRs welcome — this is the rough edge where the project gets sharper.
@@ -70,7 +70,7 @@ Most health apps lock your data behind proprietary clouds, push subscriptions, a
## Key Features
**Health Metrics** -- Track weight, blood pressure, pulse, body fat, sleep, steps, blood glucose (fasting/postprandial/random/bedtime, mg/dL ↔ mmol/L), total body water, bone mass, and pulse oximetry (SpO₂) with interactive trend charts, moving averages, and traffic-light ranges based on ESH 2023, ADA 2024, and consensus pulse-oximeter guidance (NICE NG115). Body-composition + SpO₂ metrics sync automatically from Withings Body+ scales and ScanWatch devices.
**Health Metrics** -- Track weight, blood pressure, pulse, body fat, sleep, steps, blood glucose (fasting/postprandial/random/bedtime, mg/dL ↔ mmol/L), total body water, bone mass, and pulse oximetry (SpO₂) with interactive trend charts, moving averages, and traffic-light ranges based on ESH 2023, ADA 2024, and consensus pulse-oximeter guidance (NICE NG115). Body-composition + SpO₂ metrics sync automatically from Withings Body+ scales and ScanWatch devices. Every stored HealthKit metric now has a chart surface — flights climbed, audio exposure, walking speed/step-length/asymmetry/double-support, respiratory rate, the body-composition family, mobility, daylight, and more — and a metric/imperial display toggle renders walking speed in km/h while storage and exports stay canonical SI.
**Custom Thresholds** -- Override the computed default ranges per metric with the targets your clinician set. Audit-logged. Doctor Report PDF prints both your target and the standard reference.
@@ -78,23 +78,25 @@ Most health apps lock your data behind proprietary clouds, push subscriptions, a
**Mood Logging** -- 5-point scale with tags, notes, and trend analytics. Syncs automatically from moodLog.app via webhook.
**Medication Compliance** -- Flexible scheduling with time windows, day-of-week recurrence, and `intervalWeeks` (so once-weekly GLP-1 injections and weekday-only doses score honestly, not against a daily denominator). Take / skip / snooze logging, compliance heatmaps, GLP-1 pen-inventory + injection-site rotation, structured side-effect tracking. External API for iOS Shortcuts integration.
**Medication Compliance** -- Flexible scheduling driven by a single recurrence engine: time windows, day-of-week recurrence, `intervalWeeks`, rolling intervals, RFC-5545 RRULE, one-time doses, as-needed (PRN), and cyclic on/off-week plans — so once-weekly GLP-1 injections, weekday-only doses, and non-daily plans score honestly against their real cadence rather than a daily denominator, everywhere from the dashboard tile to the detail page. Route of administration (oral / injection / other) carries the injection-site picker for any injection. Take / skip / snooze logging, compliance heatmaps, GLP-1 pen-inventory + injection-site rotation, structured side-effect tracking. External API for iOS Shortcuts integration.
**Withings Integration** -- OAuth2 device sync for scales, blood pressure monitors, and activity trackers with automatic deduplication.
**Apple Health import** -- Drop your iOS `export.zip` on the import page. A streaming parser handles multi-gigabyte archives (Zip64), folds every `<Record>`, `<Workout>`, `<Correlation>`, and `<ClinicalRecord>` into the same timeline as your other metrics, and stays idempotent on re-upload. Per-type ingestion stats plus a live status endpoint so you can watch the progress on a long historical drain.
**AI Coach + Insights** -- A conversational Coach grounded in your own data, a daily briefing, a weekly report, and a Health Score tile on the dashboard. Pick OpenAI, Anthropic Claude, ChatGPT via Codex device-OAuth (no API key needed), or any OpenAI-compatible local endpoint (Ollama, LM Studio, vLLM). BYOK or admin-shared. Every claim links back to the measurements that produced it. Local endpoints keep all data on your network.
**AI Coach + Insights** -- A conversational Coach grounded in your own data, a daily briefing, a weekly report, and a Health Score tile on the dashboard. Pick OpenAI, Anthropic Claude, ChatGPT via Codex device-OAuth (no API key needed), or any OpenAI-compatible local endpoint (Ollama, LM Studio, vLLM). BYOK or admin-shared. Feed the Coach a chosen set of data clusters (cardiovascular, body composition, activity, workouts, sleep, mood, glucose, medication, mobility, environment) with a soft budget cap that degrades the lowest-signal clusters first. Every claim links back to the measurements that produced it. Local endpoints keep all data on your network.
**Doctor Report PDF Export** -- Generate professional medical reports client-side. Locale-aware (English/German), with vital sign summaries, BP/BMI/glucose classification, compliance rates, custom-threshold badges, and optional AI analysis.
**Health-record export (PDF + FHIR R4)** -- `POST /api/export/health-record` produces a selectable export: an enriched clinical PDF, a machine-readable HL7 FHIR R4 document bundle (LOINC-coded observations, a BP panel, medication statements, a diagnostic report), or both packaged as one zip. The selection chooses date range and per-domain sections; both formats read the same aggregator so they describe identical numbers, and the optional AI summary is an explicit opt-in section marked as not clinically validated. Optional patient identity (name, insurer, insurance number) on Account feeds the report cover and the FHIR `Patient`.
**Built-in Feedback** -- Send bug reports and feature requests from inside the app. Stored in your HealthLog database — no GitHub config required. Optional GitHub escalation for admins.
**PWA with Offline Support** -- Installable on iOS and Android. Service worker with intelligent caching strategies for reliable offline access.
**PWA with Offline Support** -- Installable on iOS and Android. Service worker with intelligent caching strategies for reliable offline access. A paginated, opaque-cursor sync delta feed (`GET /api/sync/changes`) with measurement tombstones lets native and offline clients reconcile against the server incrementally.
**Multi-Channel Notifications** -- Telegram (with inline action buttons), ntfy (self-hostable), Web Push (VAPID), and Apple Push (APNs) for the native iOS client. One dispatcher fans medication reminders out to whichever channels the user has enabled, with late/missed escalation.
**Sub-second dashboard** -- A persistent rollup tier pre-aggregates every measurement at DAY / WEEK / MONTH granularity. The dashboard and analytics paths read those buckets first and fall back to live SQL only when a tenant hasn't been backfilled yet, so paint time stays under 500 ms regardless of how many years of history you've imported.
**Sub-second dashboard** -- A persistent rollup tier pre-aggregates every measurement at DAY / WEEK / MONTH granularity. The dashboard and analytics paths read those buckets first and fall back to live SQL only when a tenant hasn't been backfilled yet, so paint time stays under 500 ms regardless of how many years of history you've imported. The above-the-fold tiles arrive together in a single first-paint snapshot, and a nightly job pre-generates the daily briefing so `/insights` is a cache read rather than a synchronous model call.
**Gamification** -- 59 persistent achievements across intake streaks, compliance milestones, and healthy metric streaks.
@@ -153,7 +155,7 @@ Open **http://localhost:3000**. The first registered user becomes admin.
| PDF | jsPDF (client-side generation) |
| Testing | Vitest 4 |
| Deployment | Docker (multi-stage Alpine) |
| Native client | SwiftUI iOS app — v1.5, public beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa) |
| Native client | SwiftUI iOS app — public beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa) |
---
@@ -439,7 +441,7 @@ All mutations require authentication via session cookie. External ingest uses Be
## Local Development
```bash
# Prerequisites: Node.js 20+, pnpm, PostgreSQL
# Prerequisites: Node.js 22, pnpm, PostgreSQL
cp .env.example .env
pnpm install
@@ -485,7 +487,8 @@ For a single-process default the same container hosts both the web and worker (`
| Release line | Focus |
| ------------ | ----- |
| **v1.4.x** | Web maturity — Apple Health import, AI Coach, persistent rollup tier, multi-provider AI, doctor PDF, encryption-key rotation, Coolify autodeploy. Roughly weekly cadence. |
| **v1.5** (current) | Native iOS client (SwiftUI) in public beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa). Backend contract locked in [`docs/api/openapi.yaml`](docs/api/openapi.yaml); the iOS app lives in a separate repository and ingests via the same `/api/measurements/batch` and `/api/auth/refresh` surfaces the web uses. |
| **v1.5** | Native iOS client (SwiftUI) in public beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa). Backend contract locked in [`docs/api/openapi.yaml`](docs/api/openapi.yaml); the iOS app lives in a separate repository and ingests via the same `/api/measurements/batch` and `/api/auth/refresh` surfaces the web uses. |
| **v1.6 – v1.7** (current) | Medication editor overhaul + route of administration (v1.6), then health-record export (PDF + HL7 FHIR R4), flexible schedules (RRULE / rolling / interval-weeks / one-time / PRN / cyclic) with cadence-canonical compliance, full HealthKit metric coverage, a metric/imperial display preference, a first-paint dashboard snapshot, and a sync delta feed for offline clients (v1.7). |
| **v2.x** (planned) | Multi-tenant hardening, expanded device passthrough (Garmin / Polar), opt-in cross-user aggregate research mode (off by default; never enabled without explicit consent). |
The detailed changelog lives in [`CHANGELOG.md`](CHANGELOG.md).
+1199 -42
View File
File diff suppressed because it is too large. Load diff
+435 -13
View File
@@ -148,7 +148,17 @@
"sleep": "Schlafdauer",
"empty": "Für den gewählten Zeitraum liegen noch keine Daten vor. Der Bericht enthält dennoch das Deckblatt.",
"unavailableHint": "Keine Daten in diesem Zeitraum"
}
},
"insurer": "Krankenkasse",
"insuranceNumber": "Versichertennr.",
"summaryTitle": "Klinische Zusammenfassung",
"summaryReadings": "Über {days} Tage erfasst: {count} Messwerte über {params} Parameter.",
"summaryTrend": "{label}: zuletzt {latest} {arrow}",
"summaryAdherence": "Medikamenten-Einnahmetreue: {rate}% (gewichteter Mittelwert).",
"chartsTitle": "Trend-Diagramme",
"aiSummaryTitle": "KI-Zusammenfassung — nicht klinisch validiert",
"aiSummaryDisclaimer": "Dieser Abschnitt wurde automatisch von einem KI-Modell erzeugt und ist nicht klinisch validiert. Er ersetzt keine ärztliche Beurteilung.",
"packageReadme": "HealthLog Gesundheitsakte\n\nDieses Paket enthält:\n- report.pdf: den Gesundheitsbericht für die ärztliche Vorlage.\n- bundle.json: dieselben Daten als HL7-FHIR-R4-Dokument (Bundle), ePA-kompatibel.\n\nErstellt mit HealthLog."
},
"errorBoundary": {
"title": "Da ist etwas schiefgelaufen",
@@ -1150,7 +1160,6 @@
"intake": {
"title": "Einnahmeverlauf",
"importButton": "Importieren",
"viewAllLink": "Vollständigen Verlauf öffnen →",
"rowActions": {
"openMenu": "Zeilen-Aktionen öffnen",
"edit": "Bearbeiten",
@@ -1199,10 +1208,6 @@
},
"settings": {
"title": "Einstellungen",
"csvImport": {
"title": "CSV-Import",
"stub": "Importiere Einnahmen direkt aus dem Verlauf oben."
},
"phases": {
"openButton": "Phasen konfigurieren",
"requiresCourseWindow": "Phasen sind nur mit Kurs-Fenster verfügbar."
@@ -1273,7 +1278,28 @@
"advancedOption": "Erweiterte Einstellungen"
},
"advanced": {
"title": "Erweiterte Einstellungen"
"title": "Erweiterte Einstellungen",
"group": {
"data": "Daten",
"reminders": "Erinnerungen",
"lifecycle": "Lebenszyklus",
"danger": "Gefahrenzone"
},
"csvImport": {
"title": "CSV-/JSON-Import",
"helper": "Vergangene Einnahmen aus einer Datei oder eingefügtem JSON importieren.",
"button": "CSV/JSON importieren"
}
},
"header": {
"historyLabel": "Einnahmeverlauf ansehen",
"advancedLabel": "Erweiterte Einstellungen"
},
"history": {
"back": "Zurück zum Medikament",
"subtitle": "Vollständiger Einnahmeverlauf",
"plannedSuffix": "geplant",
"drugLevelDisclosure": "Geschätzte Wirkstoffkurve anzeigen"
}
}
},
@@ -1592,7 +1618,33 @@
"vo2_max": "VO₂ max",
"body_temp": "Körpertemperatur",
"medications": "Medikamente",
"anthropometrics": "Profil (Größe, Alter, Geschlecht)"
"anthropometrics": "Profil (Größe, Alter, Geschlecht)",
"walking_hr": "Geh-Herzfrequenz",
"respiratory_rate": "Atemfrequenz",
"spo2": "Sauerstoffsättigung",
"pulse_wave_velocity": "Pulswellengeschwindigkeit",
"vascular_age": "Gefäßalter",
"body_fat": "Körperfett",
"fat_mass": "Fettmasse",
"fat_free_mass": "Fettfreie Masse",
"muscle_mass": "Muskelmasse",
"lean_body_mass": "Magermasse",
"bone_mass": "Knochenmasse",
"total_body_water": "Körperwasser",
"bmi": "BMI",
"visceral_fat": "Viszeralfett",
"glucose": "Glukose",
"walking_steadiness": "Gehstabilität",
"walking_asymmetry": "Gangasymmetrie",
"walking_double_support": "Doppelstandphase",
"walking_step_length": "Schrittlänge",
"walking_speed": "Gehgeschwindigkeit",
"audio_env": "Umgebungslautstärke",
"audio_headphone": "Kopfhörer-Lautstärke",
"audio_event": "Lärm-Ereignisse",
"daylight": "Zeit im Tageslicht",
"skin_temp": "Hauttemperatur",
"workouts": "Workouts"
},
"window": {
"last7days": "letzte 7 Tage",
@@ -1630,7 +1682,52 @@
"dailyLimitTitle": "Heute-Limit erreicht",
"dailyLimitBody": "Heute-Limit erreicht; Reset um 00:00 UTC.",
"providerRateLimitTitle": "Provider rate-limited",
"providerRateLimitBody": "Provider ist temporär überlastet; Reset in ~5 min."
"providerRateLimitBody": "Provider ist temporär überlastet; Reset in ~5 min.",
"cluster": {
"cardio": {
"label": "Herz-Kreislauf",
"hint": "Blutdruck · Puls · HRV · Ruhe- & Geh-Herzfrequenz"
},
"body": {
"label": "Körperzusammensetzung",
"hint": "Gewicht · Körperfett · Muskeln · BMI"
},
"activity": {
"label": "Aktivität",
"hint": "Schritte · Energie · Distanz · VO₂max"
},
"workouts": {
"label": "Workouts",
"hint": "Läufe · Radtouren · Einheiten"
},
"sleep": {
"label": "Schlaf",
"hint": "Dauer · Phasen"
},
"mood": {
"label": "Stimmung",
"hint": "Tägliche Stimmungseinträge"
},
"glucose": {
"label": "Glukose",
"hint": "Nüchtern- & postprandiale Werte"
},
"medication": {
"label": "Medikation",
"hint": "Therapietreue · GLP-1-Kontext"
},
"mobility": {
"label": "Mobilität & Gang",
"hint": "Stabilität · Asymmetrie · Gangbild"
},
"environment": {
"label": "Umgebung",
"hint": "Lärm · Tageslicht · Hauttemperatur"
}
},
"settingsClustersLabel": "Daten, die der Coach sieht",
"settingsClustersHint": "Wähle, welche Datencluster der Coach lesen darf. Mehr Daten bedeuten reichhaltigere Antworten und einen größeren Prompt.",
"settingsClusterNoData": "Noch keine Daten"
},
"relativeJustNow": "gerade eben",
"relativeMinutesAgoOne": "vor {count} Minute",
@@ -1657,6 +1754,30 @@
"vitalsParent": {
"label": "Vitalwerte",
"header": "Vital-Werte"
},
"bodyParent": {
"label": "Körper",
"header": "Körperzusammensetzung"
},
"activityParent": {
"label": "Aktivität",
"header": "Aktivität & Mobilität"
},
"cardiovascularParent": {
"label": "Herz-Kreislauf",
"header": "Herz-Kreislauf-Marker"
},
"hearingParent": {
"label": "Gehör",
"header": "Gehör & Lärmbelastung"
},
"environmentParent": {
"label": "Umwelt",
"header": "Umwelt"
},
"metabolicParent": {
"label": "Stoffwechsel",
"header": "Stoffwechsel"
}
},
"hrv": {
@@ -1859,7 +1980,247 @@
"mixedCardio": "Mixed Cardio",
"other": "Sonstiges"
}
}
},
"bloodGlucose": {
"title": "Blutzucker",
"description": "Deine Blutzuckerwerte im Zeitverlauf, in mg/dL.",
"chartTitle": "Blutzucker",
"emptyState": {
"title": "Noch keine Glukosedaten",
"description": "Blutzuckerwerte erscheinen hier, sobald du einen erfasst oder ein Gerät synchronisiert."
}
},
"navBloodGlucose": "Glukose",
"totalBodyWater": {
"title": "Gesamtkörperwasser",
"description": "Dein Gesamtkörperwasser im Zeitverlauf, in Kilogramm.",
"chartTitle": "Gesamtkörperwasser",
"emptyState": {
"title": "Noch keine Körperwasserdaten",
"description": "Körperwasserwerte erscheinen hier, sobald eine Körperanalysewaage synchronisiert."
}
},
"navTotalBodyWater": "Körperwasser",
"boneMass": {
"title": "Knochenmasse",
"description": "Deine Knochenmasse im Zeitverlauf, in Kilogramm.",
"chartTitle": "Knochenmasse",
"emptyState": {
"title": "Noch keine Knochenmassedaten",
"description": "Knochenmassewerte erscheinen hier, sobald eine Körperanalysewaage synchronisiert."
}
},
"navBoneMass": "Knochenmasse",
"flightsClimbed": {
"title": "Treppen",
"description": "Stockwerke, die du jeden Tag erklommen hast.",
"chartTitle": "Treppen pro Tag",
"emptyState": {
"title": "Noch keine Treppendaten",
"description": "Treppenwerte erscheinen hier, sobald Apple Health einen Tag synchronisiert."
}
},
"navFlightsClimbed": "Treppen",
"walkingRunningDistance": {
"title": "Geh- + Laufstrecke",
"description": "Geh- und Laufstrecke pro Tag, in Kilometern.",
"chartTitle": "Strecke pro Tag",
"emptyState": {
"title": "Noch keine Streckendaten",
"description": "Tagesstrecke erscheint hier, sobald Apple Health einen Tag synchronisiert."
}
},
"navWalkingRunningDistance": "Strecke",
"fatFreeMass": {
"title": "Fettfreie Masse",
"description": "Deine fettfreie Masse im Zeitverlauf, in Kilogramm.",
"chartTitle": "Fettfreie Masse",
"emptyState": {
"title": "Noch keine Daten zur fettfreien Masse",
"description": "Werte erscheinen hier, sobald eine Körperanalysewaage synchronisiert."
}
},
"navFatFreeMass": "Fettfreie Masse",
"fatMass": {
"title": "Fettmasse",
"description": "Deine Fettmasse im Zeitverlauf, in Kilogramm.",
"chartTitle": "Fettmasse",
"emptyState": {
"title": "Noch keine Fettmassedaten",
"description": "Fettmassewerte erscheinen hier, sobald eine Körperanalysewaage synchronisiert."
}
},
"navFatMass": "Fettmasse",
"muscleMass": {
"title": "Muskelmasse",
"description": "Deine Muskelmasse im Zeitverlauf, in Kilogramm.",
"chartTitle": "Muskelmasse",
"emptyState": {
"title": "Noch keine Muskelmassedaten",
"description": "Muskelmassewerte erscheinen hier, sobald eine Körperanalysewaage synchronisiert."
}
},
"navMuscleMass": "Muskelmasse",
"skinTemperature": {
"title": "Hauttemperatur",
"description": "Deine Handgelenk-Hauttemperatur im Zeitverlauf, in Grad Celsius.",
"chartTitle": "Hauttemperatur",
"emptyState": {
"title": "Noch keine Hauttemperaturdaten",
"description": "Werte erscheinen hier, sobald ein Wearable die nächtliche Temperatur synchronisiert."
}
},
"navSkinTemperature": "Hauttemperatur",
"pulseWaveVelocity": {
"title": "Pulswellengeschwindigkeit",
"description": "Deine arterielle Pulswellengeschwindigkeit im Zeitverlauf, in m/s — höhere Werte deuten auf steifere Arterien hin.",
"chartTitle": "Pulswellengeschwindigkeit",
"emptyState": {
"title": "Noch keine Daten",
"description": "Werte erscheinen hier, sobald ein kompatibles Withings-Gerät synchronisiert."
}
},
"navPulseWaveVelocity": "Pulswellengeschwindigkeit",
"vascularAge": {
"title": "Gefäßalter",
"description": "Dein geschätztes Gefäßalter im Zeitverlauf, in Jahren.",
"chartTitle": "Gefäßalter",
"emptyState": {
"title": "Noch keine Gefäßalterdaten",
"description": "Schätzungen erscheinen hier, sobald ein kompatibles Withings-Gerät synchronisiert."
}
},
"navVascularAge": "Gefäßalter",
"visceralFat": {
"title": "Viszeralfett",
"description": "Dein Viszeralfett-Index im Zeitverlauf.",
"chartTitle": "Viszeralfett",
"emptyState": {
"title": "Noch keine Viszeralfettdaten",
"description": "Werte erscheinen hier, sobald eine kompatible Körperanalysewaage synchronisiert."
}
},
"navVisceralFat": "Viszeralfett",
"audioExposureEnv": {
"title": "Umgebungslärm",
"description": "Dein täglicher mittlerer Umgebungs-Geräuschpegel, in dBA.",
"chartTitle": "Umgebungs-Geräuschpegel",
"emptyState": {
"title": "Noch keine Umgebungslärmdaten",
"description": "Pegelwerte erscheinen hier, sobald Apple Health synchronisiert."
}
},
"navAudioExposureEnv": "Umgebungslärm",
"audioExposureHeadphone": {
"title": "Kopfhörerpegel",
"description": "Dein täglicher mittlerer Kopfhörerpegel, in dBA.",
"chartTitle": "Kopfhörer-Lautstärke",
"emptyState": {
"title": "Noch keine Kopfhörerdaten",
"description": "Pegelwerte erscheinen hier, sobald Apple Health synchronisiert."
}
},
"navAudioExposureHeadphone": "Kopfhörerpegel",
"timeInDaylight": {
"title": "Zeit im Tageslicht",
"description": "Minuten im Tageslicht pro Tag.",
"chartTitle": "Tageslicht pro Tag",
"emptyState": {
"title": "Noch keine Tageslichtdaten",
"description": "Tageslichtwerte erscheinen hier, sobald Apple Health einen Tag synchronisiert."
}
},
"navTimeInDaylight": "Tageslicht",
"walkingSteadiness": {
"title": "Gangstabilität",
"description": "Deine Gangstabilität im Zeitverlauf, in Prozent.",
"chartTitle": "Gangstabilität",
"emptyState": {
"title": "Noch keine Gangstabilitätsdaten",
"description": "Werte erscheinen hier, sobald Apple Health synchronisiert."
}
},
"navWalkingSteadiness": "Gangstabilität",
"audioExposureEvent": {
"title": "Laute Lärmereignisse",
"description": "Tage mit einem lauten Umgebungsereignis.",
"chartTitle": "Lärmereignisse pro Tag",
"emptyState": {
"title": "Noch keine Lärmereignisdaten",
"description": "Lärmereignisse erscheinen hier, sobald Apple Health synchronisiert."
}
},
"navAudioExposureEvent": "Lärmereignisse",
"respiratoryRate": {
"title": "Atemfrequenz",
"description": "Deine Atemfrequenz im Zeitverlauf, in Atemzügen pro Minute.",
"chartTitle": "Atemfrequenz",
"emptyState": {
"title": "Noch keine Atemfrequenzdaten",
"description": "Werte erscheinen hier, sobald Apple Health Schlaf- oder Trainingsatmung synchronisiert."
}
},
"navRespiratoryRate": "Atemfrequenz",
"leanBodyMass": {
"title": "Magermasse",
"description": "Deine Magermasse im Zeitverlauf, in Kilogramm.",
"chartTitle": "Magermasse",
"emptyState": {
"title": "Noch keine Magermassedaten",
"description": "Werte erscheinen hier, sobald eine Körperanalysewaage synchronisiert."
}
},
"navLeanBodyMass": "Magermasse",
"walkingHeartRateAverage": {
"title": "Geh-Puls",
"description": "Dein durchschnittlicher Puls beim Gehen, in bpm.",
"chartTitle": "Durchschnittlicher Geh-Puls",
"emptyState": {
"title": "Noch keine Geh-Puls-Daten",
"description": "Werte erscheinen hier, sobald Apple Health synchronisiert."
}
},
"navWalkingHeartRateAverage": "Geh-Puls",
"walkingAsymmetry": {
"title": "Gangasymmetrie",
"description": "Deine Gangasymmetrie im Zeitverlauf, in Prozent.",
"chartTitle": "Gangasymmetrie",
"emptyState": {
"title": "Noch keine Gangasymmetriedaten",
"description": "Werte erscheinen hier, sobald Apple Health synchronisiert."
}
},
"navWalkingAsymmetry": "Gangasymmetrie",
"walkingDoubleSupport": {
"title": "Doppelstandphase",
"description": "Der Anteil jedes Schritts mit beiden Füßen am Boden, in Prozent.",
"chartTitle": "Doppelstandphase",
"emptyState": {
"title": "Noch keine Doppelstanddaten",
"description": "Werte erscheinen hier, sobald Apple Health synchronisiert."
}
},
"navWalkingDoubleSupport": "Doppelstandphase",
"walkingStepLength": {
"title": "Schrittlänge",
"description": "Deine durchschnittliche Schrittlänge im Zeitverlauf, in Metern.",
"chartTitle": "Schrittlänge",
"emptyState": {
"title": "Noch keine Schrittlängendaten",
"description": "Werte erscheinen hier, sobald Apple Health synchronisiert."
}
},
"navWalkingStepLength": "Schrittlänge",
"walkingSpeed": {
"title": "Gehgeschwindigkeit",
"description": "Deine durchschnittliche Gehgeschwindigkeit im Zeitverlauf, in km/h.",
"chartTitle": "Gehgeschwindigkeit (km/h)",
"emptyState": {
"title": "Noch keine Gehgeschwindigkeitsdaten",
"description": "Werte erscheinen hier, sobald Apple Health synchronisiert."
}
},
"navWalkingSpeed": "Gehgeschwindigkeit"
},
"thresholds": {
"defaultLabel": "Default",
@@ -2393,6 +2754,16 @@
"saveError": "Speichern fehlgeschlagen. Bitte erneut versuchen."
}
},
"dashboard": {
"units": {
"title": "Einheiten",
"description": "Lege fest, wie Distanzen und Geschwindigkeiten in Diagrammen und Kacheln angezeigt werden.",
"metric": "Metrisch",
"imperial": "Imperial",
"saved": "Einheiten-Einstellung gespeichert.",
"saveError": "Einheiten-Einstellung konnte nicht gespeichert werden."
}
},
"withings": "Withings",
"withingsDescription": "Verbinde deine Withings-Waage und Blutdruckmessgeräte.",
"withingsCredentials": "API-Zugangsdaten",
@@ -2559,6 +2930,54 @@
"nextRetry": "Nächster Versuch",
"reEnable": "Wieder aktivieren",
"sendTest": "Test senden"
},
"identity": {
"description": "Optionale Angaben für die Gesundheitsakte (PDF-Deckblatt + FHIR-Export). Alle Felder sind freiwillig.",
"fullName": "Vollständiger Name",
"fullNamePlaceholder": "z. B. Erika Mustermann",
"insurer": "Krankenkasse",
"insurerPlaceholder": "z. B. AOK Bayern",
"insuranceNumber": "Versichertennummer (KVNR)",
"insuranceNumberHint": "10 Zeichen: ein Buchstabe + neun Ziffern (z. B. A123456780)."
},
"healthRecord": {
"title": "Gesundheitsakte exportieren",
"description": "Erstelle eine vollständige Gesundheitsakte für die ärztliche Vorlage — als PDF, strukturiert (FHIR) oder als kombiniertes Paket.",
"format": "Format",
"format_pdf": "PDF",
"format_fhir": "Strukturiert (FHIR)",
"format_package": "Paket: PDF + FHIR",
"range": "Zeitraum",
"range90": "Letzte 90 Tage",
"range180": "Letzte 6 Monate",
"range365": "Letzte 12 Monate",
"practiceName": "Praxis (optional)",
"practiceNamePlaceholder": "z. B. Dr. med. Mustermann",
"includedData": "Enthaltene Daten",
"groupVitals": "Vitalwerte",
"groupCardio": "Herz & Fitness",
"groupActivity": "Aktivität & Schlaf",
"weight": "Gewicht",
"bp": "Blutdruck",
"pulse": "Puls",
"oxygenSaturation": "Sauerstoffsättigung",
"bodyFat": "Körperfett",
"bodyComposition": "Körperzusammensetzung",
"restingHeartRate": "Ruhepuls",
"hrv": "Herzfrequenzvariabilität",
"vo2max": "VO₂max",
"steps": "Schritte",
"distance": "Distanz",
"sleep": "Schlaf",
"glucose": "Blutzucker",
"medications": "Medikamente & Einnahmetreue",
"mood": "Stimmung (standardmäßig aus — sensible Daten)",
"bmi": "BMI",
"includeCharts": "Trend-Diagramme einbetten (nur PDF)",
"includeAiSummary": "KI-Zusammenfassung anhängen (nicht klinisch validiert)",
"fhirNote": "Die strukturierte Ausgabe folgt dem FHIR-R4-Standard (ePA-kompatibel).",
"generate": "Erstellen",
"error": "Export fehlgeschlagen ({code})."
}
},
"admin": {
@@ -3346,13 +3765,16 @@
},
"moodReminder": {
"title": "Tägliche Stimmungs-Erinnerung",
"description": "Sanfter Anstoß um 22:00 Uhr, falls du heute noch keine Stimmung erfasst hast. Greift nur auf bereits eingerichtete Kanäle (Telegram, ntfy, Web Push, Apple Push) zurück und schweigt, sobald du heute geloggt hast.",
"description": "Sanfter Anstoß zur eingestellten Zeit, falls du heute noch keine Stimmung erfasst hast. Greift nur auf bereits eingerichtete Kanäle (Telegram, ntfy, Web Push, Apple Push) zurück und schweigt, sobald du heute geloggt hast.",
"toggleAria": "Tägliche Stimmungs-Erinnerung umschalten",
"statusOn": "Aktiv um 22:00 deiner Zeit",
"statusOn": "Aktiv zu deiner eingestellten Zeit",
"statusOff": "Aus",
"enabledToast": "Stimmungs-Erinnerung aktiviert.",
"disabledToast": "Stimmungs-Erinnerung deaktiviert.",
"saveError": "Speichern fehlgeschlagen. Bitte später erneut versuchen."
"saveError": "Speichern fehlgeschlagen. Bitte später erneut versuchen.",
"hourLabel": "Erinnerungszeit",
"hourAria": "Stunde der Stimmungs-Erinnerung",
"hourSavedToast": "Erinnerungszeit aktualisiert."
},
"eventMedicationReminder": "Medikamenten-Erinnerung",
"eventMedicationReminderDesc": "Wird zu jeder geplanten Einnahmezeit gesendet.",
+435 -13
View File
@@ -148,7 +148,17 @@
"sleep": "Sleep duration",
"empty": "No data was recorded in this date range yet. The report will still include the cover page.",
"unavailableHint": "No data in this period"
}
},
"insurer": "Health insurer",
"insuranceNumber": "Insurance no.",
"summaryTitle": "Clinical summary",
"summaryReadings": "Recorded over {days} days: {count} measurements across {params} parameters.",
"summaryTrend": "{label}: latest {latest} {arrow}",
"summaryAdherence": "Medication adherence: {rate}% (weighted mean).",
"chartsTitle": "Trend charts",
"aiSummaryTitle": "AI summary — not clinically validated",
"aiSummaryDisclaimer": "This section was generated automatically by an AI model and is not clinically validated. It does not replace a medical assessment.",
"packageReadme": "HealthLog health record\n\nThis package contains:\n- report.pdf: the health report for your doctor.\n- bundle.json: the same data as an HL7 FHIR R4 document Bundle, ePA-compatible.\n\nGenerated with HealthLog."
},
"errorBoundary": {
"title": "Something went wrong",
@@ -1150,7 +1160,6 @@
"intake": {
"title": "Intake history",
"importButton": "Import",
"viewAllLink": "View full history →",
"rowActions": {
"openMenu": "Open row actions",
"edit": "Edit",
@@ -1199,10 +1208,6 @@
},
"settings": {
"title": "Settings",
"csvImport": {
"title": "CSV import",
"stub": "Import intakes directly from the history above."
},
"phases": {
"openButton": "Configure phases",
"requiresCourseWindow": "Phases are available once a course window is set."
@@ -1273,7 +1278,28 @@
"advancedOption": "Advanced settings"
},
"advanced": {
"title": "Advanced settings"
"title": "Advanced settings",
"group": {
"data": "Data",
"reminders": "Reminders",
"lifecycle": "Lifecycle",
"danger": "Danger zone"
},
"csvImport": {
"title": "CSV / JSON import",
"helper": "Import past intakes from a file or pasted JSON.",
"button": "Import CSV/JSON"
}
},
"header": {
"historyLabel": "View intake history",
"advancedLabel": "Advanced settings"
},
"history": {
"back": "Back to medication",
"subtitle": "Full intake history",
"plannedSuffix": "planned",
"drugLevelDisclosure": "Show estimated drug-level curve"
}
}
},
@@ -1592,7 +1618,33 @@
"vo2_max": "VO₂ max",
"body_temp": "Body temperature",
"medications": "Medications",
"anthropometrics": "Profile (height, age, gender)"
"anthropometrics": "Profile (height, age, gender)",
"walking_hr": "Walking heart rate",
"respiratory_rate": "Respiratory rate",
"spo2": "Blood oxygen",
"pulse_wave_velocity": "Pulse-wave velocity",
"vascular_age": "Vascular age",
"body_fat": "Body fat",
"fat_mass": "Fat mass",
"fat_free_mass": "Fat-free mass",
"muscle_mass": "Muscle mass",
"lean_body_mass": "Lean body mass",
"bone_mass": "Bone mass",
"total_body_water": "Body water",
"bmi": "BMI",
"visceral_fat": "Visceral fat",
"glucose": "Glucose",
"walking_steadiness": "Walking steadiness",
"walking_asymmetry": "Walking asymmetry",
"walking_double_support": "Double support time",
"walking_step_length": "Step length",
"walking_speed": "Walking speed",
"audio_env": "Environmental sound",
"audio_headphone": "Headphone audio",
"audio_event": "Loud-sound events",
"daylight": "Time in daylight",
"skin_temp": "Skin temperature",
"workouts": "Workouts"
},
"window": {
"last7days": "last 7 days",
@@ -1630,7 +1682,52 @@
"dailyLimitTitle": "Daily limit reached",
"dailyLimitBody": "Daily limit reached; resets at 00:00 UTC.",
"providerRateLimitTitle": "Provider rate-limited",
"providerRateLimitBody": "Provider temporarily rate-limited; retry in ~5 min."
"providerRateLimitBody": "Provider temporarily rate-limited; retry in ~5 min.",
"cluster": {
"cardio": {
"label": "Cardiovascular",
"hint": "BP · pulse · HRV · resting & walking HR"
},
"body": {
"label": "Body composition",
"hint": "Weight · body fat · muscle · BMI"
},
"activity": {
"label": "Activity",
"hint": "Steps · energy · distance · VO₂ max"
},
"workouts": {
"label": "Workouts",
"hint": "Runs · rides · sessions"
},
"sleep": {
"label": "Sleep",
"hint": "Duration · stages"
},
"mood": {
"label": "Mood",
"hint": "Daily mood entries"
},
"glucose": {
"label": "Glucose",
"hint": "Fasting · postprandial readings"
},
"medication": {
"label": "Medication",
"hint": "Adherence · GLP-1 context"
},
"mobility": {
"label": "Mobility & gait",
"hint": "Steadiness · asymmetry · gait"
},
"environment": {
"label": "Environment",
"hint": "Noise · daylight · skin temp"
}
},
"settingsClustersLabel": "Data the Coach sees",
"settingsClustersHint": "Pick which clusters of your data the Coach can read. More data means richer answers and a larger prompt.",
"settingsClusterNoData": "No data yet"
},
"relativeJustNow": "just now",
"relativeMinutesAgoOne": "{count} minute ago",
@@ -1657,6 +1754,30 @@
"vitalsParent": {
"label": "Vitals",
"header": "Vital signs"
},
"bodyParent": {
"label": "Body",
"header": "Body composition"
},
"activityParent": {
"label": "Activity",
"header": "Activity & mobility"
},
"cardiovascularParent": {
"label": "Cardiovascular",
"header": "Cardiovascular markers"
},
"hearingParent": {
"label": "Hearing",
"header": "Hearing & audio exposure"
},
"environmentParent": {
"label": "Environment",
"header": "Environment"
},
"metabolicParent": {
"label": "Metabolic",
"header": "Metabolic"
}
},
"hrv": {
@@ -1859,7 +1980,247 @@
"mixedCardio": "Mixed cardio",
"other": "Other"
}
}
},
"bloodGlucose": {
"title": "Blood Glucose",
"description": "Your blood-glucose readings over time, in mg/dL.",
"chartTitle": "Blood glucose",
"emptyState": {
"title": "No glucose data yet",
"description": "Blood-glucose readings appear here once you log one or a connected device syncs."
}
},
"navBloodGlucose": "Glucose",
"totalBodyWater": {
"title": "Total Body Water",
"description": "Your total body water over time, in kilograms.",
"chartTitle": "Total body water",
"emptyState": {
"title": "No body-water data yet",
"description": "Body-water readings appear here once a connected body-composition scale syncs."
}
},
"navTotalBodyWater": "Body Water",
"boneMass": {
"title": "Bone Mass",
"description": "Your bone mass over time, in kilograms.",
"chartTitle": "Bone mass",
"emptyState": {
"title": "No bone-mass data yet",
"description": "Bone-mass readings appear here once a connected body-composition scale syncs."
}
},
"navBoneMass": "Bone Mass",
"flightsClimbed": {
"title": "Flights Climbed",
"description": "Flights of stairs you climbed each day.",
"chartTitle": "Flights climbed per day",
"emptyState": {
"title": "No flights data yet",
"description": "Flights-climbed totals appear here once Apple Health syncs a day's movement."
}
},
"navFlightsClimbed": "Flights",
"walkingRunningDistance": {
"title": "Walking + Running Distance",
"description": "Walking and running distance you covered each day, in kilometres.",
"chartTitle": "Distance per day",
"emptyState": {
"title": "No distance data yet",
"description": "Daily distance appears here once Apple Health syncs a day's movement."
}
},
"navWalkingRunningDistance": "Distance",
"fatFreeMass": {
"title": "Fat-Free Mass",
"description": "Your fat-free mass over time, in kilograms.",
"chartTitle": "Fat-free mass",
"emptyState": {
"title": "No fat-free-mass data yet",
"description": "Fat-free-mass readings appear here once a connected body-composition scale syncs."
}
},
"navFatFreeMass": "Fat-Free Mass",
"fatMass": {
"title": "Fat Mass",
"description": "Your fat mass over time, in kilograms.",
"chartTitle": "Fat mass",
"emptyState": {
"title": "No fat-mass data yet",
"description": "Fat-mass readings appear here once a connected body-composition scale syncs."
}
},
"navFatMass": "Fat Mass",
"muscleMass": {
"title": "Muscle Mass",
"description": "Your muscle mass over time, in kilograms.",
"chartTitle": "Muscle mass",
"emptyState": {
"title": "No muscle-mass data yet",
"description": "Muscle-mass readings appear here once a connected body-composition scale syncs."
}
},
"navMuscleMass": "Muscle Mass",
"skinTemperature": {
"title": "Skin Temperature",
"description": "Your wrist skin temperature over time, in degrees Celsius.",
"chartTitle": "Skin temperature",
"emptyState": {
"title": "No skin-temperature data yet",
"description": "Skin-temperature readings appear here once a wearable syncs overnight wrist temperature."
}
},
"navSkinTemperature": "Skin Temp",
"pulseWaveVelocity": {
"title": "Pulse Wave Velocity",
"description": "Your arterial pulse-wave velocity over time, in m/s — higher values suggest stiffer arteries.",
"chartTitle": "Pulse wave velocity",
"emptyState": {
"title": "No pulse-wave-velocity data yet",
"description": "Pulse-wave-velocity readings appear here once a compatible Withings device syncs."
}
},
"navPulseWaveVelocity": "Pulse Wave Velocity",
"vascularAge": {
"title": "Vascular Age",
"description": "Your estimated vascular age over time, in years.",
"chartTitle": "Vascular age",
"emptyState": {
"title": "No vascular-age data yet",
"description": "Vascular-age estimates appear here once a compatible Withings device syncs."
}
},
"navVascularAge": "Vascular Age",
"visceralFat": {
"title": "Visceral Fat",
"description": "Your visceral-fat rating over time.",
"chartTitle": "Visceral fat",
"emptyState": {
"title": "No visceral-fat data yet",
"description": "Visceral-fat ratings appear here once a compatible body-composition scale syncs."
}
},
"navVisceralFat": "Visceral Fat",
"audioExposureEnv": {
"title": "Environmental Audio",
"description": "Your daily average environmental sound exposure, in dBA.",
"chartTitle": "Environmental sound exposure",
"emptyState": {
"title": "No environmental-audio data yet",
"description": "Environmental sound-level readings appear here once Apple Health syncs."
}
},
"navAudioExposureEnv": "Environmental Audio",
"audioExposureHeadphone": {
"title": "Headphone Audio",
"description": "Your daily average headphone audio exposure, in dBA.",
"chartTitle": "Headphone audio exposure",
"emptyState": {
"title": "No headphone-audio data yet",
"description": "Headphone audio-level readings appear here once Apple Health syncs."
}
},
"navAudioExposureHeadphone": "Headphone Audio",
"timeInDaylight": {
"title": "Time in Daylight",
"description": "Minutes spent in daylight each day.",
"chartTitle": "Time in daylight per day",
"emptyState": {
"title": "No daylight data yet",
"description": "Daylight totals appear here once Apple Health syncs a day's exposure."
}
},
"navTimeInDaylight": "Daylight",
"walkingSteadiness": {
"title": "Walking Steadiness",
"description": "Your walking steadiness over time, as a percentage.",
"chartTitle": "Walking steadiness",
"emptyState": {
"title": "No walking-steadiness data yet",
"description": "Walking-steadiness readings appear here once Apple Health syncs."
}
},
"navWalkingSteadiness": "Walking Steadiness",
"audioExposureEvent": {
"title": "Loud Audio Events",
"description": "Days with a loud-environment exposure event.",
"chartTitle": "Loud-environment events per day",
"emptyState": {
"title": "No loud-event data yet",
"description": "Loud-environment events appear here once Apple Health syncs."
}
},
"navAudioExposureEvent": "Loud Events",
"respiratoryRate": {
"title": "Respiratory Rate",
"description": "Your respiratory rate over time, in breaths per minute.",
"chartTitle": "Respiratory rate",
"emptyState": {
"title": "No respiratory-rate data yet",
"description": "Respiratory-rate readings appear here once Apple Health syncs sleep or workout breathing."
}
},
"navRespiratoryRate": "Respiratory Rate",
"leanBodyMass": {
"title": "Lean Body Mass",
"description": "Your lean body mass over time, in kilograms.",
"chartTitle": "Lean body mass",
"emptyState": {
"title": "No lean-body-mass data yet",
"description": "Lean-body-mass readings appear here once a connected body-composition scale syncs."
}
},
"navLeanBodyMass": "Lean Body Mass",
"walkingHeartRateAverage": {
"title": "Walking Heart Rate",
"description": "Your average heart rate while walking, in bpm.",
"chartTitle": "Walking heart rate average",
"emptyState": {
"title": "No walking-heart-rate data yet",
"description": "Walking-heart-rate readings appear here once Apple Health syncs."
}
},
"navWalkingHeartRateAverage": "Walking Heart Rate",
"walkingAsymmetry": {
"title": "Walking Asymmetry",
"description": "Your walking asymmetry over time, as a percentage.",
"chartTitle": "Walking asymmetry",
"emptyState": {
"title": "No walking-asymmetry data yet",
"description": "Walking-asymmetry readings appear here once Apple Health syncs."
}
},
"navWalkingAsymmetry": "Walking Asymmetry",
"walkingDoubleSupport": {
"title": "Double Support Time",
"description": "The percentage of each stride spent with both feet on the ground.",
"chartTitle": "Double support time",
"emptyState": {
"title": "No double-support data yet",
"description": "Double-support readings appear here once Apple Health syncs."
}
},
"navWalkingDoubleSupport": "Double Support",
"walkingStepLength": {
"title": "Walking Step Length",
"description": "Your average walking step length over time, in metres.",
"chartTitle": "Step length",
"emptyState": {
"title": "No step-length data yet",
"description": "Step-length readings appear here once Apple Health syncs."
}
},
"navWalkingStepLength": "Step Length",
"walkingSpeed": {
"title": "Walking Speed",
"description": "Your average walking speed over time, in km/h.",
"chartTitle": "Walking speed (km/h)",
"emptyState": {
"title": "No walking-speed data yet",
"description": "Walking-speed readings appear here once Apple Health syncs."
}
},
"navWalkingSpeed": "Walking Speed"
},
"thresholds": {
"defaultLabel": "Default",
@@ -2393,6 +2754,16 @@
"saveError": "Could not save the change. Please try again."
}
},
"dashboard": {
"units": {
"title": "Units",
"description": "Choose how distances and speeds are shown across charts and tiles.",
"metric": "Metric",
"imperial": "Imperial",
"saved": "Unit preference saved.",
"saveError": "Could not save your unit preference."
}
},
"withings": "Withings",
"withingsDescription": "Connect your Withings scale and blood pressure monitors.",
"withingsCredentials": "API Credentials",
@@ -2559,6 +2930,54 @@
"nextRetry": "Next retry",
"reEnable": "Re-enable",
"sendTest": "Send test"
},
"identity": {
"description": "Optional details for the health-record export (PDF cover + FHIR export). All fields are optional.",
"fullName": "Full name",
"fullNamePlaceholder": "e.g. Jane Doe",
"insurer": "Health insurer",
"insurerPlaceholder": "e.g. AOK Bayern",
"insuranceNumber": "Insurance number (KVNR)",
"insuranceNumberHint": "10 characters: one letter + nine digits (e.g. A123456780)."
},
"healthRecord": {
"title": "Export health record",
"description": "Generate a complete health record for your doctor — as a PDF, structured (FHIR), or a combined package.",
"format": "Format",
"format_pdf": "PDF",
"format_fhir": "Structured (FHIR)",
"format_package": "Package: PDF + FHIR",
"range": "Period",
"range90": "Last 90 days",
"range180": "Last 6 months",
"range365": "Last 12 months",
"practiceName": "Practice (optional)",
"practiceNamePlaceholder": "e.g. Dr. Smith",
"includedData": "Included data",
"groupVitals": "Vital signs",
"groupCardio": "Heart & fitness",
"groupActivity": "Activity & sleep",
"weight": "Weight",
"bp": "Blood pressure",
"pulse": "Pulse",
"oxygenSaturation": "Oxygen saturation",
"bodyFat": "Body fat",
"bodyComposition": "Body composition",
"restingHeartRate": "Resting heart rate",
"hrv": "Heart rate variability",
"vo2max": "VO₂max",
"steps": "Steps",
"distance": "Distance",
"sleep": "Sleep",
"glucose": "Blood glucose",
"medications": "Medications & adherence",
"mood": "Mood (off by default — sensitive data)",
"bmi": "BMI",
"includeCharts": "Embed trend charts (PDF only)",
"includeAiSummary": "Append AI summary (not clinically validated)",
"fhirNote": "The structured output follows the FHIR R4 standard (ePA-compatible).",
"generate": "Generate",
"error": "Export failed ({code})."
}
},
"admin": {
@@ -3346,13 +3765,16 @@
},
"moodReminder": {
"title": "Daily mood reminder",
"description": "Gentle 22:00 nudge when you haven't logged a mood for the day. Routes only through channels you've already configured (Telegram, ntfy, Web Push, Apple Push) and stays silent once you've logged today.",
"description": "A gentle nudge at your chosen time when you haven't logged a mood for the day. Routes only through channels you've already configured (Telegram, ntfy, Web Push, Apple Push) and stays silent once you've logged today.",
"toggleAria": "Toggle daily mood reminder",
"statusOn": "On at 22:00 your time",
"statusOn": "Active at your chosen time",
"statusOff": "Off",
"enabledToast": "Mood reminder enabled.",
"disabledToast": "Mood reminder disabled.",
"saveError": "Save failed. Please try again."
"saveError": "Save failed. Please try again.",
"hourLabel": "Reminder time",
"hourAria": "Mood reminder hour",
"hourSavedToast": "Reminder time updated."
},
"eventMedicationReminder": "Medication reminder",
"eventMedicationReminderDesc": "Sent at each scheduled dose time.",
+435 -13
View File
@@ -148,7 +148,17 @@
"sleep": "Duración del sueño",
"empty": "No hay datos para el periodo seleccionado. El informe se generará con la portada.",
"unavailableHint": "Sin datos en este periodo"
}
},
"insurer": "Seguro médico",
"insuranceNumber": "N.º de asegurado",
"summaryTitle": "Resumen clínico",
"summaryReadings": "Registrado durante {days} días: {count} mediciones en {params} parámetros.",
"summaryTrend": "{label}: último {latest} {arrow}",
"summaryAdherence": "Adherencia a la medicación: {rate}% (media ponderada).",
"chartsTitle": "Gráficos de tendencia",
"aiSummaryTitle": "Resumen de IA — no validado clínicamente",
"aiSummaryDisclaimer": "Esta sección fue generada automáticamente por un modelo de IA y no está validada clínicamente. No sustituye una evaluación médica.",
"packageReadme": "Historial de salud de HealthLog\n\nEste paquete contiene:\n- report.pdf: el informe de salud para su médico.\n- bundle.json: los mismos datos como Bundle de documento HL7 FHIR R4, compatible con ePA.\n\nGenerado con HealthLog."
},
"errorBoundary": {
"title": "Algo ha salido mal",
@@ -1150,7 +1160,6 @@
"intake": {
"title": "Intake history",
"importButton": "Import",
"viewAllLink": "View full history →",
"rowActions": {
"openMenu": "Open row actions",
"edit": "Edit",
@@ -1199,10 +1208,6 @@
},
"settings": {
"title": "Settings",
"csvImport": {
"title": "CSV import",
"stub": "Import intakes directly from the history above."
},
"phases": {
"openButton": "Configure phases",
"requiresCourseWindow": "Phases are available once a course window is set."
@@ -1273,7 +1278,28 @@
"advancedOption": "Ajustes avanzados"
},
"advanced": {
"title": "Ajustes avanzados"
"title": "Ajustes avanzados",
"group": {
"data": "Datos",
"reminders": "Recordatorios",
"lifecycle": "Ciclo de vida",
"danger": "Zona de peligro"
},
"csvImport": {
"title": "Importar CSV/JSON",
"helper": "Importa tomas anteriores desde un archivo o JSON pegado.",
"button": "Importar CSV/JSON"
}
},
"header": {
"historyLabel": "Ver historial de tomas",
"advancedLabel": "Configuración avanzada"
},
"history": {
"back": "Volver al medicamento",
"subtitle": "Historial completo de tomas",
"plannedSuffix": "planificado",
"drugLevelDisclosure": "Mostrar curva estimada del principio activo"
}
}
},
@@ -1592,7 +1618,33 @@
"vo2_max": "VO₂ máx",
"body_temp": "Temperatura corporal",
"medications": "Medicamentos",
"anthropometrics": "Perfil (altura, edad, sexo)"
"anthropometrics": "Perfil (altura, edad, sexo)",
"walking_hr": "Frecuencia cardiaca al caminar",
"respiratory_rate": "Frecuencia respiratoria",
"spo2": "Oxígeno en sangre",
"pulse_wave_velocity": "Velocidad de onda de pulso",
"vascular_age": "Edad vascular",
"body_fat": "Grasa corporal",
"fat_mass": "Masa grasa",
"fat_free_mass": "Masa libre de grasa",
"muscle_mass": "Masa muscular",
"lean_body_mass": "Masa corporal magra",
"bone_mass": "Masa ósea",
"total_body_water": "Agua corporal",
"bmi": "IMC",
"visceral_fat": "Grasa visceral",
"glucose": "Glucosa",
"walking_steadiness": "Estabilidad al caminar",
"walking_asymmetry": "Asimetría al caminar",
"walking_double_support": "Tiempo de doble apoyo",
"walking_step_length": "Longitud de paso",
"walking_speed": "Velocidad al caminar",
"audio_env": "Sonido ambiental",
"audio_headphone": "Audio de auriculares",
"audio_event": "Eventos de sonido fuerte",
"daylight": "Tiempo a la luz del día",
"skin_temp": "Temperatura de la piel",
"workouts": "Entrenamientos"
},
"window": {
"last7days": "Últimos 7 días",
@@ -1630,7 +1682,52 @@
"providerRateLimitTitle": "Proveedor con límite de tasa",
"providerRateLimitBody": "El proveedor está temporalmente saturado; reinicio en ~5 min.",
"settingsContextLabel": "Enviar contexto",
"settingsContextHint": "Bloques opcionales en los que el coach puede apoyarse. Si desactivas alguno, se omitirá en cada respuesta."
"settingsContextHint": "Bloques opcionales en los que el coach puede apoyarse. Si desactivas alguno, se omitirá en cada respuesta.",
"cluster": {
"cardio": {
"label": "Cardiovascular",
"hint": "Presión · pulso · VFC · FC en reposo y al caminar"
},
"body": {
"label": "Composición corporal",
"hint": "Peso · grasa · músculo · IMC"
},
"activity": {
"label": "Actividad",
"hint": "Pasos · energía · distancia · VO₂ máx"
},
"workouts": {
"label": "Entrenamientos",
"hint": "Carreras · paseos · sesiones"
},
"sleep": {
"label": "Sueño",
"hint": "Duración · fases"
},
"mood": {
"label": "Estado de ánimo",
"hint": "Registros diarios de ánimo"
},
"glucose": {
"label": "Glucosa",
"hint": "Lecturas en ayunas y posprandiales"
},
"medication": {
"label": "Medicación",
"hint": "Adherencia · contexto GLP-1"
},
"mobility": {
"label": "Movilidad y marcha",
"hint": "Estabilidad · asimetría · marcha"
},
"environment": {
"label": "Entorno",
"hint": "Ruido · luz del día · temp. piel"
}
},
"settingsClustersLabel": "Datos que ve el Coach",
"settingsClustersHint": "Elige qué grupos de datos puede leer el Coach. Más datos significan respuestas más ricas y un prompt mayor.",
"settingsClusterNoData": "Aún no hay datos"
},
"relativeJustNow": "ahora mismo",
"relativeMinutesAgoOne": "hace {count} minuto",
@@ -1657,6 +1754,30 @@
"vitalsParent": {
"label": "Vitales",
"header": "Constantes vitales"
},
"bodyParent": {
"label": "Cuerpo",
"header": "Composición corporal"
},
"activityParent": {
"label": "Actividad",
"header": "Actividad y movilidad"
},
"cardiovascularParent": {
"label": "Cardiovascular",
"header": "Marcadores cardiovasculares"
},
"hearingParent": {
"label": "Audición",
"header": "Audición y exposición sonora"
},
"environmentParent": {
"label": "Entorno",
"header": "Entorno"
},
"metabolicParent": {
"label": "Metabólico",
"header": "Metabólico"
}
},
"hrv": {
@@ -1859,7 +1980,247 @@
"mixedCardio": "Cardio mixto",
"other": "Otro"
}
}
},
"bloodGlucose": {
"title": "Glucosa en sangre",
"description": "Tus lecturas de glucosa en el tiempo, en mg/dL.",
"chartTitle": "Glucosa en sangre",
"emptyState": {
"title": "Aún no hay datos de glucosa",
"description": "Las lecturas de glucosa aparecen aquí cuando registras una o un dispositivo sincroniza."
}
},
"navBloodGlucose": "Glucosa",
"totalBodyWater": {
"title": "Agua corporal total",
"description": "Tu agua corporal total en el tiempo, en kilogramos.",
"chartTitle": "Agua corporal total",
"emptyState": {
"title": "Aún no hay datos de agua corporal",
"description": "Las lecturas de agua corporal aparecen aquí cuando una báscula de composición se sincroniza."
}
},
"navTotalBodyWater": "Agua corporal",
"boneMass": {
"title": "Masa ósea",
"description": "Tu masa ósea en el tiempo, en kilogramos.",
"chartTitle": "Masa ósea",
"emptyState": {
"title": "Aún no hay datos de masa ósea",
"description": "Las lecturas de masa ósea aparecen aquí cuando una báscula de composición se sincroniza."
}
},
"navBoneMass": "Masa ósea",
"flightsClimbed": {
"title": "Pisos subidos",
"description": "Tramos de escaleras que subiste cada día.",
"chartTitle": "Pisos subidos por día",
"emptyState": {
"title": "Aún no hay datos de pisos",
"description": "Los totales de pisos aparecen aquí cuando Apple Health sincroniza un día."
}
},
"navFlightsClimbed": "Pisos",
"walkingRunningDistance": {
"title": "Distancia a pie + corriendo",
"description": "Distancia caminando y corriendo cada día, en kilómetros.",
"chartTitle": "Distancia por día",
"emptyState": {
"title": "Aún no hay datos de distancia",
"description": "La distancia diaria aparece aquí cuando Apple Health sincroniza un día."
}
},
"navWalkingRunningDistance": "Distancia",
"fatFreeMass": {
"title": "Masa libre de grasa",
"description": "Tu masa libre de grasa en el tiempo, en kilogramos.",
"chartTitle": "Masa libre de grasa",
"emptyState": {
"title": "Aún no hay datos",
"description": "Las lecturas aparecen aquí cuando una báscula de composición se sincroniza."
}
},
"navFatFreeMass": "Masa magra",
"fatMass": {
"title": "Masa grasa",
"description": "Tu masa grasa en el tiempo, en kilogramos.",
"chartTitle": "Masa grasa",
"emptyState": {
"title": "Aún no hay datos de masa grasa",
"description": "Las lecturas aparecen aquí cuando una báscula de composición se sincroniza."
}
},
"navFatMass": "Masa grasa",
"muscleMass": {
"title": "Masa muscular",
"description": "Tu masa muscular en el tiempo, en kilogramos.",
"chartTitle": "Masa muscular",
"emptyState": {
"title": "Aún no hay datos de masa muscular",
"description": "Las lecturas aparecen aquí cuando una báscula de composición se sincroniza."
}
},
"navMuscleMass": "Masa muscular",
"skinTemperature": {
"title": "Temperatura de la piel",
"description": "La temperatura de la piel de tu muñeca en el tiempo, en grados Celsius.",
"chartTitle": "Temperatura de la piel",
"emptyState": {
"title": "Aún no hay datos",
"description": "Las lecturas aparecen aquí cuando un wearable sincroniza la temperatura nocturna."
}
},
"navSkinTemperature": "Temp. piel",
"pulseWaveVelocity": {
"title": "Velocidad de onda de pulso",
"description": "Tu velocidad de onda de pulso arterial en el tiempo, en m/s — valores más altos sugieren arterias más rígidas.",
"chartTitle": "Velocidad de onda de pulso",
"emptyState": {
"title": "Aún no hay datos",
"description": "Las lecturas aparecen aquí cuando un dispositivo Withings compatible se sincroniza."
}
},
"navPulseWaveVelocity": "Velocidad de onda de pulso",
"vascularAge": {
"title": "Edad vascular",
"description": "Tu edad vascular estimada en el tiempo, en años.",
"chartTitle": "Edad vascular",
"emptyState": {
"title": "Aún no hay datos",
"description": "Las estimaciones aparecen aquí cuando un dispositivo Withings compatible se sincroniza."
}
},
"navVascularAge": "Edad vascular",
"visceralFat": {
"title": "Grasa visceral",
"description": "Tu índice de grasa visceral en el tiempo.",
"chartTitle": "Grasa visceral",
"emptyState": {
"title": "Aún no hay datos",
"description": "Los índices aparecen aquí cuando una báscula de composición compatible se sincroniza."
}
},
"navVisceralFat": "Grasa visceral",
"audioExposureEnv": {
"title": "Ruido ambiental",
"description": "Tu exposición media diaria a sonido ambiental, en dBA.",
"chartTitle": "Exposición a sonido ambiental",
"emptyState": {
"title": "Aún no hay datos",
"description": "Las lecturas de nivel sonoro aparecen aquí cuando Apple Health sincroniza."
}
},
"navAudioExposureEnv": "Ruido ambiental",
"audioExposureHeadphone": {
"title": "Audio de auriculares",
"description": "Tu exposición media diaria con auriculares, en dBA.",
"chartTitle": "Exposición de auriculares",
"emptyState": {
"title": "Aún no hay datos",
"description": "Las lecturas aparecen aquí cuando Apple Health sincroniza."
}
},
"navAudioExposureHeadphone": "Audio auriculares",
"timeInDaylight": {
"title": "Tiempo con luz diurna",
"description": "Minutos al día con luz diurna.",
"chartTitle": "Luz diurna por día",
"emptyState": {
"title": "Aún no hay datos",
"description": "Los totales aparecen aquí cuando Apple Health sincroniza un día."
}
},
"navTimeInDaylight": "Luz diurna",
"walkingSteadiness": {
"title": "Estabilidad al andar",
"description": "Tu estabilidad al andar en el tiempo, en porcentaje.",
"chartTitle": "Estabilidad al andar",
"emptyState": {
"title": "Aún no hay datos",
"description": "Las lecturas aparecen aquí cuando Apple Health sincroniza."
}
},
"navWalkingSteadiness": "Estabilidad al andar",
"audioExposureEvent": {
"title": "Eventos de audio fuerte",
"description": "Días con un evento de exposición a ruido fuerte.",
"chartTitle": "Eventos de ruido por día",
"emptyState": {
"title": "Aún no hay datos",
"description": "Los eventos aparecen aquí cuando Apple Health sincroniza."
}
},
"navAudioExposureEvent": "Eventos de ruido",
"respiratoryRate": {
"title": "Frecuencia respiratoria",
"description": "Tu frecuencia respiratoria en el tiempo, en respiraciones por minuto.",
"chartTitle": "Frecuencia respiratoria",
"emptyState": {
"title": "Aún no hay datos",
"description": "Las lecturas aparecen aquí cuando Apple Health sincroniza la respiración del sueño o del entrenamiento."
}
},
"navRespiratoryRate": "Frecuencia respiratoria",
"leanBodyMass": {
"title": "Masa corporal magra",
"description": "Tu masa corporal magra en el tiempo, en kilogramos.",
"chartTitle": "Masa corporal magra",
"emptyState": {
"title": "Aún no hay datos",
"description": "Las lecturas aparecen aquí cuando una báscula de composición se sincroniza."
}
},
"navLeanBodyMass": "Masa corporal magra",
"walkingHeartRateAverage": {
"title": "Pulso al caminar",
"description": "Tu frecuencia cardíaca media al caminar, en lpm.",
"chartTitle": "Pulso medio al caminar",
"emptyState": {
"title": "Aún no hay datos",
"description": "Las lecturas aparecen aquí cuando Apple Health sincroniza."
}
},
"navWalkingHeartRateAverage": "Pulso al caminar",
"walkingAsymmetry": {
"title": "Asimetría al andar",
"description": "Tu asimetría al andar en el tiempo, en porcentaje.",
"chartTitle": "Asimetría al andar",
"emptyState": {
"title": "Aún no hay datos",
"description": "Las lecturas aparecen aquí cuando Apple Health sincroniza."
}
},
"navWalkingAsymmetry": "Asimetría al andar",
"walkingDoubleSupport": {
"title": "Tiempo de doble apoyo",
"description": "El porcentaje de cada zancada con ambos pies en el suelo.",
"chartTitle": "Tiempo de doble apoyo",
"emptyState": {
"title": "Aún no hay datos",
"description": "Las lecturas aparecen aquí cuando Apple Health sincroniza."
}
},
"navWalkingDoubleSupport": "Doble apoyo",
"walkingStepLength": {
"title": "Longitud de paso",
"description": "Tu longitud de paso media en el tiempo, en metros.",
"chartTitle": "Longitud de paso",
"emptyState": {
"title": "Aún no hay datos",
"description": "Las lecturas aparecen aquí cuando Apple Health sincroniza."
}
},
"navWalkingStepLength": "Longitud de paso",
"walkingSpeed": {
"title": "Velocidad al andar",
"description": "Tu velocidad media al andar en el tiempo, en km/h.",
"chartTitle": "Velocidad al andar (km/h)",
"emptyState": {
"title": "Aún no hay datos",
"description": "Las lecturas aparecen aquí cuando Apple Health sincroniza."
}
},
"navWalkingSpeed": "Velocidad al andar"
},
"thresholds": {
"defaultLabel": "Por defecto",
@@ -2393,6 +2754,16 @@
"saveError": "No se pudo guardar el cambio. Inténtalo de nuevo."
}
},
"dashboard": {
"units": {
"title": "Unidades",
"description": "Elige cómo se muestran las distancias y velocidades en los gráficos y mosaicos.",
"metric": "Métrico",
"imperial": "Imperial",
"saved": "Preferencia de unidades guardada.",
"saveError": "No se pudo guardar tu preferencia de unidades."
}
},
"withings": "Withings",
"withingsDescription": "Connect your Withings scale and blood pressure monitors.",
"withingsCredentials": "API Credentials",
@@ -2559,6 +2930,54 @@
"nextRetry": "Next retry",
"reEnable": "Re-enable",
"sendTest": "Send test"
},
"identity": {
"description": "Datos opcionales para la exportación del historial de salud (portada PDF + exportación FHIR). Todos los campos son opcionales.",
"fullName": "Nombre completo",
"fullNamePlaceholder": "p. ej. Ana García",
"insurer": "Seguro médico",
"insurerPlaceholder": "p. ej. AOK Bayern",
"insuranceNumber": "Número de asegurado (KVNR)",
"insuranceNumberHint": "10 caracteres: una letra + nueve dígitos (p. ej. A123456780)."
},
"healthRecord": {
"title": "Exportar historial de salud",
"description": "Genere un historial de salud completo para su médico — como PDF, estructurado (FHIR) o un paquete combinado.",
"format": "Formato",
"format_pdf": "PDF",
"format_fhir": "Estructurado (FHIR)",
"format_package": "Paquete: PDF + FHIR",
"range": "Período",
"range90": "Últimos 90 días",
"range180": "Últimos 6 meses",
"range365": "Últimos 12 meses",
"practiceName": "Consulta (opcional)",
"practiceNamePlaceholder": "p. ej. Dr. García",
"includedData": "Datos incluidos",
"groupVitals": "Signos vitales",
"groupCardio": "Corazón y forma física",
"groupActivity": "Actividad y sueño",
"weight": "Peso",
"bp": "Presión arterial",
"pulse": "Pulso",
"oxygenSaturation": "Saturación de oxígeno",
"bodyFat": "Grasa corporal",
"bodyComposition": "Composición corporal",
"restingHeartRate": "Pulso en reposo",
"hrv": "Variabilidad de la frecuencia cardíaca",
"vo2max": "VO₂máx",
"steps": "Pasos",
"distance": "Distancia",
"sleep": "Sueño",
"glucose": "Glucosa en sangre",
"medications": "Medicamentos y adherencia",
"mood": "Estado de ánimo (desactivado por defecto — datos sensibles)",
"bmi": "IMC",
"includeCharts": "Incrustar gráficos de tendencia (solo PDF)",
"includeAiSummary": "Añadir resumen de IA (no validado clínicamente)",
"fhirNote": "La salida estructurada sigue el estándar FHIR R4 (compatible con ePA).",
"generate": "Generar",
"error": "Error en la exportación ({code})."
}
},
"admin": {
@@ -3346,13 +3765,16 @@
},
"moodReminder": {
"title": "Recordatorio diario de ánimo",
"description": "Aviso suave a las 22:00 si aún no has registrado el ánimo del día. Se envía solo por canales ya configurados (Telegram, ntfy, Web Push, Apple Push) y permanece en silencio una vez que has registrado el ánimo hoy.",
"description": "Aviso suave a la hora que elijas si aún no has registrado el ánimo del día. Se envía solo por canales ya configurados (Telegram, ntfy, Web Push, Apple Push) y permanece en silencio una vez que has registrado el ánimo hoy.",
"toggleAria": "Alternar recordatorio diario de ánimo",
"statusOn": "Activo a las 22:00 hora local",
"statusOn": "Activo a la hora que elijas",
"statusOff": "Desactivado",
"enabledToast": "Recordatorio de ánimo activado.",
"disabledToast": "Recordatorio de ánimo desactivado.",
"saveError": "No se pudo guardar. Inténtalo de nuevo."
"saveError": "No se pudo guardar. Inténtalo de nuevo.",
"hourLabel": "Hora del recordatorio",
"hourAria": "Hora del recordatorio de ánimo",
"hourSavedToast": "Hora del recordatorio actualizada."
},
"eventMedicationReminder": "Recordatorio de medicación",
"eventMedicationReminderDesc": "Se envía a cada hora de dosis programada.",
+435 -13
View File
@@ -148,7 +148,17 @@
"sleep": "Durée du sommeil",
"empty": "Pas de données sur la période sélectionnée. Le rapport conservera la page de garde.",
"unavailableHint": "Pas de données sur cette période"
}
},
"insurer": "Assurance maladie",
"insuranceNumber": "N° d’assuré",
"summaryTitle": "Résumé clinique",
"summaryReadings": "Enregistré sur {days} jours : {count} mesures sur {params} paramètres.",
"summaryTrend": "{label} : dernier {latest} {arrow}",
"summaryAdherence": "Observance médicamenteuse : {rate}% (moyenne pondérée).",
"chartsTitle": "Graphiques de tendance",
"aiSummaryTitle": "Résumé IA — non validé cliniquement",
"aiSummaryDisclaimer": "Cette section a été générée automatiquement par un modèle d’IA et n’est pas validée cliniquement. Elle ne remplace pas une évaluation médicale.",
"packageReadme": "Dossier de santé HealthLog\n\nCe paquet contient :\n- report.pdf : le rapport de santé pour votre médecin.\n- bundle.json : les mêmes données sous forme de Bundle document HL7 FHIR R4, compatible ePA.\n\nGénéré avec HealthLog."
},
"errorBoundary": {
"title": "Une erreur est survenue",
@@ -1150,7 +1160,6 @@
"intake": {
"title": "Intake history",
"importButton": "Import",
"viewAllLink": "View full history →",
"rowActions": {
"openMenu": "Open row actions",
"edit": "Edit",
@@ -1199,10 +1208,6 @@
},
"settings": {
"title": "Settings",
"csvImport": {
"title": "CSV import",
"stub": "Import intakes directly from the history above."
},
"phases": {
"openButton": "Configure phases",
"requiresCourseWindow": "Phases are available once a course window is set."
@@ -1273,7 +1278,28 @@
"advancedOption": "Paramètres avancés"
},
"advanced": {
"title": "Paramètres avancés"
"title": "Paramètres avancés",
"group": {
"data": "Données",
"reminders": "Rappels",
"lifecycle": "Cycle de vie",
"danger": "Zone de danger"
},
"csvImport": {
"title": "Import CSV/JSON",
"helper": "Importez les prises passées depuis un fichier ou du JSON collé.",
"button": "Importer CSV/JSON"
}
},
"header": {
"historyLabel": "Voir l'historique des prises",
"advancedLabel": "Paramètres avancés"
},
"history": {
"back": "Retour au médicament",
"subtitle": "Historique complet des prises",
"plannedSuffix": "prévu",
"drugLevelDisclosure": "Afficher la courbe estimée du principe actif"
}
}
},
@@ -1592,7 +1618,33 @@
"vo2_max": "VO₂ max",
"body_temp": "Température corporelle",
"medications": "Médicaments",
"anthropometrics": "Profil (taille, âge, sexe)"
"anthropometrics": "Profil (taille, âge, sexe)",
"walking_hr": "Fréquence cardiaque à la marche",
"respiratory_rate": "Fréquence respiratoire",
"spo2": "Oxygène sanguin",
"pulse_wave_velocity": "Vitesse de l'onde de pouls",
"vascular_age": "Âge vasculaire",
"body_fat": "Masse grasse",
"fat_mass": "Masse grasse",
"fat_free_mass": "Masse sans graisse",
"muscle_mass": "Masse musculaire",
"lean_body_mass": "Masse maigre",
"bone_mass": "Masse osseuse",
"total_body_water": "Eau corporelle",
"bmi": "IMC",
"visceral_fat": "Graisse viscérale",
"glucose": "Glycémie",
"walking_steadiness": "Stabilité à la marche",
"walking_asymmetry": "Asymétrie de marche",
"walking_double_support": "Temps de double appui",
"walking_step_length": "Longueur de foulée",
"walking_speed": "Vitesse de marche",
"audio_env": "Son ambiant",
"audio_headphone": "Audio du casque",
"audio_event": "Événements sonores forts",
"daylight": "Temps à la lumière du jour",
"skin_temp": "Température cutanée",
"workouts": "Entraînements"
},
"window": {
"last7days": "7 derniers jours",
@@ -1630,7 +1682,52 @@
"providerRateLimitTitle": "Fournisseur limité en débit",
"providerRateLimitBody": "Le fournisseur est temporairement saturé ; réinitialisation dans ~5 min.",
"settingsContextLabel": "Envoyer le contexte",
"settingsContextHint": "Blocs optionnels sur lesquels le coach peut s’appuyer. Si tu en désactives un, il sera omis dans chaque réponse."
"settingsContextHint": "Blocs optionnels sur lesquels le coach peut s’appuyer. Si tu en désactives un, il sera omis dans chaque réponse.",
"cluster": {
"cardio": {
"label": "Cardiovasculaire",
"hint": "TA · pouls · VFC · FC repos & marche"
},
"body": {
"label": "Composition corporelle",
"hint": "Poids · masse grasse · muscle · IMC"
},
"activity": {
"label": "Activité",
"hint": "Pas · énergie · distance · VO₂ max"
},
"workouts": {
"label": "Entraînements",
"hint": "Courses · sorties · séances"
},
"sleep": {
"label": "Sommeil",
"hint": "Durée · phases"
},
"mood": {
"label": "Humeur",
"hint": "Entrées d'humeur quotidiennes"
},
"glucose": {
"label": "Glycémie",
"hint": "Mesures à jeun & postprandiales"
},
"medication": {
"label": "Médication",
"hint": "Observance · contexte GLP-1"
},
"mobility": {
"label": "Mobilité & démarche",
"hint": "Stabilité · asymétrie · démarche"
},
"environment": {
"label": "Environnement",
"hint": "Bruit · lumière · temp. cutanée"
}
},
"settingsClustersLabel": "Données vues par le Coach",
"settingsClustersHint": "Choisissez les groupes de données que le Coach peut lire. Plus de données, des réponses plus riches et un prompt plus grand.",
"settingsClusterNoData": "Pas encore de données"
},
"relativeJustNow": "à l’instant",
"relativeMinutesAgoOne": "il y a {count} minute",
@@ -1657,6 +1754,30 @@
"vitalsParent": {
"label": "Constantes",
"header": "Constantes vitales"
},
"bodyParent": {
"label": "Corps",
"header": "Composition corporelle"
},
"activityParent": {
"label": "Activité",
"header": "Activité et mobilité"
},
"cardiovascularParent": {
"label": "Cardiovasculaire",
"header": "Marqueurs cardiovasculaires"
},
"hearingParent": {
"label": "Audition",
"header": "Audition et exposition sonore"
},
"environmentParent": {
"label": "Environnement",
"header": "Environnement"
},
"metabolicParent": {
"label": "Métabolique",
"header": "Métabolique"
}
},
"hrv": {
@@ -1859,7 +1980,247 @@
"mixedCardio": "Cardio mixte",
"other": "Autre"
}
}
},
"bloodGlucose": {
"title": "Glycémie",
"description": "Vos relevés de glycémie au fil du temps, en mg/dL.",
"chartTitle": "Glycémie",
"emptyState": {
"title": "Aucune donnée de glycémie",
"description": "Les relevés de glycémie apparaissent ici dès que vous en saisissez un ou qu’un appareil se synchronise."
}
},
"navBloodGlucose": "Glucose",
"totalBodyWater": {
"title": "Eau corporelle totale",
"description": "Votre eau corporelle totale au fil du temps, en kilogrammes.",
"chartTitle": "Eau corporelle totale",
"emptyState": {
"title": "Aucune donnée d’eau corporelle",
"description": "Les relevés d’eau corporelle apparaissent ici dès qu’une balance à impédance se synchronise."
}
},
"navTotalBodyWater": "Eau corporelle",
"boneMass": {
"title": "Masse osseuse",
"description": "Votre masse osseuse au fil du temps, en kilogrammes.",
"chartTitle": "Masse osseuse",
"emptyState": {
"title": "Aucune donnée de masse osseuse",
"description": "Les relevés de masse osseuse apparaissent ici dès qu’une balance à impédance se synchronise."
}
},
"navBoneMass": "Masse osseuse",
"flightsClimbed": {
"title": "Étages gravis",
"description": "Volées d’escaliers gravies chaque jour.",
"chartTitle": "Étages gravis par jour",
"emptyState": {
"title": "Aucune donnée d’étages",
"description": "Les totaux d’étages apparaissent ici dès qu’Apple Health synchronise une journée."
}
},
"navFlightsClimbed": "Étages",
"walkingRunningDistance": {
"title": "Distance marche + course",
"description": "Distance de marche et de course parcourue chaque jour, en kilomètres.",
"chartTitle": "Distance par jour",
"emptyState": {
"title": "Aucune donnée de distance",
"description": "La distance quotidienne apparaît ici dès qu’Apple Health synchronise une journée."
}
},
"navWalkingRunningDistance": "Distance",
"fatFreeMass": {
"title": "Masse sans graisse",
"description": "Votre masse sans graisse au fil du temps, en kilogrammes.",
"chartTitle": "Masse sans graisse",
"emptyState": {
"title": "Aucune donnée",
"description": "Les relevés apparaissent ici dès qu’une balance à impédance se synchronise."
}
},
"navFatFreeMass": "Masse maigre",
"fatMass": {
"title": "Masse grasse",
"description": "Votre masse grasse au fil du temps, en kilogrammes.",
"chartTitle": "Masse grasse",
"emptyState": {
"title": "Aucune donnée de masse grasse",
"description": "Les relevés apparaissent ici dès qu’une balance à impédance se synchronise."
}
},
"navFatMass": "Masse grasse",
"muscleMass": {
"title": "Masse musculaire",
"description": "Votre masse musculaire au fil du temps, en kilogrammes.",
"chartTitle": "Masse musculaire",
"emptyState": {
"title": "Aucune donnée musculaire",
"description": "Les relevés apparaissent ici dès qu’une balance à impédance se synchronise."
}
},
"navMuscleMass": "Masse musculaire",
"skinTemperature": {
"title": "Température cutanée",
"description": "La température cutanée de votre poignet au fil du temps, en degrés Celsius.",
"chartTitle": "Température cutanée",
"emptyState": {
"title": "Aucune donnée",
"description": "Les relevés apparaissent ici dès qu’un appareil synchronise la température nocturne."
}
},
"navSkinTemperature": "Temp. peau",
"pulseWaveVelocity": {
"title": "Vitesse de l’onde de pouls",
"description": "Votre vitesse d’onde de pouls artérielle au fil du temps, en m/s — des valeurs plus élevées suggèrent des artères plus rigides.",
"chartTitle": "Vitesse de l’onde de pouls",
"emptyState": {
"title": "Aucune donnée",
"description": "Les relevés apparaissent ici dès qu’un appareil Withings compatible se synchronise."
}
},
"navPulseWaveVelocity": "Vitesse onde de pouls",
"vascularAge": {
"title": "Âge vasculaire",
"description": "Votre âge vasculaire estimé au fil du temps, en années.",
"chartTitle": "Âge vasculaire",
"emptyState": {
"title": "Aucune donnée",
"description": "Les estimations apparaissent ici dès qu’un appareil Withings compatible se synchronise."
}
},
"navVascularAge": "Âge vasculaire",
"visceralFat": {
"title": "Graisse viscérale",
"description": "Votre indice de graisse viscérale au fil du temps.",
"chartTitle": "Graisse viscérale",
"emptyState": {
"title": "Aucune donnée",
"description": "Les indices apparaissent ici dès qu’une balance à impédance compatible se synchronise."
}
},
"navVisceralFat": "Graisse viscérale",
"audioExposureEnv": {
"title": "Audio ambiant",
"description": "Votre exposition quotidienne moyenne au son ambiant, en dBA.",
"chartTitle": "Exposition au son ambiant",
"emptyState": {
"title": "Aucune donnée",
"description": "Les niveaux sonores apparaissent ici dès qu’Apple Health se synchronise."
}
},
"navAudioExposureEnv": "Audio ambiant",
"audioExposureHeadphone": {
"title": "Audio du casque",
"description": "Votre exposition quotidienne moyenne au casque, en dBA.",
"chartTitle": "Exposition au casque",
"emptyState": {
"title": "Aucune donnée",
"description": "Les niveaux apparaissent ici dès qu’Apple Health se synchronise."
}
},
"navAudioExposureHeadphone": "Audio casque",
"timeInDaylight": {
"title": "Temps à la lumière du jour",
"description": "Minutes passées à la lumière du jour chaque jour.",
"chartTitle": "Lumière du jour par jour",
"emptyState": {
"title": "Aucune donnée",
"description": "Les totaux apparaissent ici dès qu’Apple Health synchronise une journée."
}
},
"navTimeInDaylight": "Lumière du jour",
"walkingSteadiness": {
"title": "Stabilité de la marche",
"description": "Votre stabilité de marche au fil du temps, en pourcentage.",
"chartTitle": "Stabilité de la marche",
"emptyState": {
"title": "Aucune donnée",
"description": "Les relevés apparaissent ici dès qu’Apple Health se synchronise."
}
},
"navWalkingSteadiness": "Stabilité de marche",
"audioExposureEvent": {
"title": "Événements sonores forts",
"description": "Jours avec un événement d’exposition sonore forte.",
"chartTitle": "Événements sonores par jour",
"emptyState": {
"title": "Aucune donnée",
"description": "Les événements apparaissent ici dès qu’Apple Health se synchronise."
}
},
"navAudioExposureEvent": "Événements sonores",
"respiratoryRate": {
"title": "Fréquence respiratoire",
"description": "Votre fréquence respiratoire au fil du temps, en respirations par minute.",
"chartTitle": "Fréquence respiratoire",
"emptyState": {
"title": "Aucune donnée",
"description": "Les relevés apparaissent ici dès qu’Apple Health synchronise la respiration du sommeil ou de l’entraînement."
}
},
"navRespiratoryRate": "Fréquence respiratoire",
"leanBodyMass": {
"title": "Masse maigre corporelle",
"description": "Votre masse maigre corporelle au fil du temps, en kilogrammes.",
"chartTitle": "Masse maigre corporelle",
"emptyState": {
"title": "Aucune donnée",
"description": "Les relevés apparaissent ici dès qu’une balance à impédance se synchronise."
}
},
"navLeanBodyMass": "Masse maigre corporelle",
"walkingHeartRateAverage": {
"title": "Pouls en marchant",
"description": "Votre fréquence cardiaque moyenne en marchant, en bpm.",
"chartTitle": "Pouls moyen en marchant",
"emptyState": {
"title": "Aucune donnée",
"description": "Les relevés apparaissent ici dès qu’Apple Health se synchronise."
}
},
"navWalkingHeartRateAverage": "Pouls en marchant",
"walkingAsymmetry": {
"title": "Asymétrie de la marche",
"description": "Votre asymétrie de marche au fil du temps, en pourcentage.",
"chartTitle": "Asymétrie de la marche",
"emptyState": {
"title": "Aucune donnée",
"description": "Les relevés apparaissent ici dès qu’Apple Health se synchronise."
}
},
"navWalkingAsymmetry": "Asymétrie de marche",
"walkingDoubleSupport": {
"title": "Temps de double appui",
"description": "Le pourcentage de chaque foulée avec les deux pieds au sol.",
"chartTitle": "Temps de double appui",
"emptyState": {
"title": "Aucune donnée",
"description": "Les relevés apparaissent ici dès qu’Apple Health se synchronise."
}
},
"navWalkingDoubleSupport": "Double appui",
"walkingStepLength": {
"title": "Longueur de pas",
"description": "Votre longueur de pas moyenne au fil du temps, en mètres.",
"chartTitle": "Longueur de pas",
"emptyState": {
"title": "Aucune donnée",
"description": "Les relevés apparaissent ici dès qu’Apple Health se synchronise."
}
},
"navWalkingStepLength": "Longueur de pas",
"walkingSpeed": {
"title": "Vitesse de marche",
"description": "Votre vitesse de marche moyenne au fil du temps, en km/h.",
"chartTitle": "Vitesse de marche (km/h)",
"emptyState": {
"title": "Aucune donnée",
"description": "Les relevés apparaissent ici dès qu’Apple Health se synchronise."
}
},
"navWalkingSpeed": "Vitesse de marche"
},
"thresholds": {
"defaultLabel": "Par défaut",
@@ -2393,6 +2754,16 @@
"saveError": "Impossible d'enregistrer la modification. Veuillez réessayer."
}
},
"dashboard": {
"units": {
"title": "Unités",
"description": "Choisissez l'affichage des distances et des vitesses dans les graphiques et les tuiles.",
"metric": "Métrique",
"imperial": "Impérial",
"saved": "Préférence d'unités enregistrée.",
"saveError": "Impossible d'enregistrer votre préférence d'unités."
}
},
"withings": "Withings",
"withingsDescription": "Connect your Withings scale and blood pressure monitors.",
"withingsCredentials": "API Credentials",
@@ -2559,6 +2930,54 @@
"nextRetry": "Next retry",
"reEnable": "Re-enable",
"sendTest": "Send test"
},
"identity": {
"description": "Informations facultatives pour l’export du dossier de santé (couverture PDF + export FHIR). Tous les champs sont facultatifs.",
"fullName": "Nom complet",
"fullNamePlaceholder": "p. ex. Jeanne Dupont",
"insurer": "Assurance maladie",
"insurerPlaceholder": "p. ex. AOK Bayern",
"insuranceNumber": "Numéro d’assuré (KVNR)",
"insuranceNumberHint": "10 caractères : une lettre + neuf chiffres (p. ex. A123456780)."
},
"healthRecord": {
"title": "Exporter le dossier de santé",
"description": "Générez un dossier de santé complet pour votre médecin — en PDF, structuré (FHIR) ou en paquet combiné.",
"format": "Format",
"format_pdf": "PDF",
"format_fhir": "Structuré (FHIR)",
"format_package": "Paquet : PDF + FHIR",
"range": "Période",
"range90": "90 derniers jours",
"range180": "6 derniers mois",
"range365": "12 derniers mois",
"practiceName": "Cabinet (facultatif)",
"practiceNamePlaceholder": "p. ex. Dr Dupont",
"includedData": "Données incluses",
"groupVitals": "Signes vitaux",
"groupCardio": "Cœur & forme",
"groupActivity": "Activité & sommeil",
"weight": "Poids",
"bp": "Tension artérielle",
"pulse": "Pouls",
"oxygenSaturation": "Saturation en oxygène",
"bodyFat": "Masse grasse",
"bodyComposition": "Composition corporelle",
"restingHeartRate": "Pouls au repos",
"hrv": "Variabilité de la fréquence cardiaque",
"vo2max": "VO₂max",
"steps": "Pas",
"distance": "Distance",
"sleep": "Sommeil",
"glucose": "Glycémie",
"medications": "Médicaments & observance",
"mood": "Humeur (désactivé par défaut — données sensibles)",
"bmi": "IMC",
"includeCharts": "Intégrer les graphiques de tendance (PDF uniquement)",
"includeAiSummary": "Ajouter le résumé IA (non validé cliniquement)",
"fhirNote": "La sortie structurée suit la norme FHIR R4 (compatible ePA).",
"generate": "Générer",
"error": "Échec de l’export ({code})."
}
},
"admin": {
@@ -3346,13 +3765,16 @@
},
"moodReminder": {
"title": "Rappel quotidien d’humeur",
"description": "Petit rappel à 22:00 si tu n’as pas encore noté ton humeur du jour. Utilise uniquement les canaux déjà configurés (Telegram, ntfy, Web Push, Apple Push) et reste silencieux dès que tu as noté ton humeur aujourd’hui.",
"description": "Petit rappel à l’heure que tu as choisie si tu n’as pas encore noté ton humeur du jour. Utilise uniquement les canaux déjà configurés (Telegram, ntfy, Web Push, Apple Push) et reste silencieux dès que tu as noté ton humeur aujourd’hui.",
"toggleAria": "Activer / désactiver le rappel quotidien d’humeur",
"statusOn": "Actif à 22:00 heure locale",
"statusOn": "Actif à l’heure que tu as choisie",
"statusOff": "Désactivé",
"enabledToast": "Rappel d’humeur activé.",
"disabledToast": "Rappel d’humeur désactivé.",
"saveError": "Échec de l’enregistrement. Réessayez."
"saveError": "Échec de l’enregistrement. Réessayez.",
"hourLabel": "Heure du rappel",
"hourAria": "Heure du rappel d’humeur",
"hourSavedToast": "Heure du rappel mise à jour."
},
"eventMedicationReminder": "Rappel de médicament",
"eventMedicationReminderDesc": "Envoyé à chaque heure de prise prévue.",
+435 -13
View File
@@ -148,7 +148,17 @@
"sleep": "Durata del sonno",
"empty": "Nessun dato per il periodo selezionato. Il referto manterrà comunque la copertina.",
"unavailableHint": "Nessun dato in questo periodo"
}
},
"insurer": "Assicurazione sanitaria",
"insuranceNumber": "N. assicurato",
"summaryTitle": "Riepilogo clinico",
"summaryReadings": "Registrato in {days} giorni: {count} misurazioni su {params} parametri.",
"summaryTrend": "{label}: ultimo {latest} {arrow}",
"summaryAdherence": "Aderenza ai farmaci: {rate}% (media ponderata).",
"chartsTitle": "Grafici di tendenza",
"aiSummaryTitle": "Riepilogo IA — non validato clinicamente",
"aiSummaryDisclaimer": "Questa sezione è stata generata automaticamente da un modello di IA e non è validata clinicamente. Non sostituisce una valutazione medica.",
"packageReadme": "Cartella clinica HealthLog\n\nQuesto pacchetto contiene:\n- report.pdf: il referto sanitario per il medico.\n- bundle.json: gli stessi dati come Bundle documento HL7 FHIR R4, compatibile ePA.\n\nGenerato con HealthLog."
},
"errorBoundary": {
"title": "Qualcosa è andato storto",
@@ -1150,7 +1160,6 @@
"intake": {
"title": "Intake history",
"importButton": "Import",
"viewAllLink": "View full history →",
"rowActions": {
"openMenu": "Open row actions",
"edit": "Edit",
@@ -1199,10 +1208,6 @@
},
"settings": {
"title": "Settings",
"csvImport": {
"title": "CSV import",
"stub": "Import intakes directly from the history above."
},
"phases": {
"openButton": "Configure phases",
"requiresCourseWindow": "Phases are available once a course window is set."
@@ -1273,7 +1278,28 @@
"advancedOption": "Impostazioni avanzate"
},
"advanced": {
"title": "Impostazioni avanzate"
"title": "Impostazioni avanzate",
"group": {
"data": "Dati",
"reminders": "Promemoria",
"lifecycle": "Ciclo di vita",
"danger": "Zona pericolosa"
},
"csvImport": {
"title": "Importa CSV/JSON",
"helper": "Importa le assunzioni passate da un file o da JSON incollato.",
"button": "Importa CSV/JSON"
}
},
"header": {
"historyLabel": "Visualizza cronologia assunzioni",
"advancedLabel": "Impostazioni avanzate"
},
"history": {
"back": "Torna al farmaco",
"subtitle": "Cronologia completa delle assunzioni",
"plannedSuffix": "pianificato",
"drugLevelDisclosure": "Mostra la curva stimata del principio attivo"
}
}
},
@@ -1592,7 +1618,33 @@
"vo2_max": "VO₂ max",
"body_temp": "Temperatura corporea",
"medications": "Farmaci",
"anthropometrics": "Profilo (altezza, età, sesso)"
"anthropometrics": "Profilo (altezza, età, sesso)",
"walking_hr": "Frequenza cardiaca camminando",
"respiratory_rate": "Frequenza respiratoria",
"spo2": "Ossigeno nel sangue",
"pulse_wave_velocity": "Velocità dell'onda di polso",
"vascular_age": "Età vascolare",
"body_fat": "Grasso corporeo",
"fat_mass": "Massa grassa",
"fat_free_mass": "Massa magra",
"muscle_mass": "Massa muscolare",
"lean_body_mass": "Massa corporea magra",
"bone_mass": "Massa ossea",
"total_body_water": "Acqua corporea",
"bmi": "IMC",
"visceral_fat": "Grasso viscerale",
"glucose": "Glicemia",
"walking_steadiness": "Stabilità del cammino",
"walking_asymmetry": "Asimmetria del cammino",
"walking_double_support": "Tempo di doppio appoggio",
"walking_step_length": "Lunghezza del passo",
"walking_speed": "Velocità del cammino",
"audio_env": "Suono ambientale",
"audio_headphone": "Audio cuffie",
"audio_event": "Eventi sonori forti",
"daylight": "Tempo alla luce del giorno",
"skin_temp": "Temperatura cutanea",
"workouts": "Allenamenti"
},
"window": {
"last7days": "Ultimi 7 giorni",
@@ -1630,7 +1682,52 @@
"providerRateLimitTitle": "Provider con rate-limit",
"providerRateLimitBody": "Il provider è temporaneamente sovraccarico; reset in ~5 min.",
"settingsContextLabel": "Invia contesto",
"settingsContextHint": "Blocchi opzionali su cui il coach può basarsi. Disattivandone uno, viene omesso da ogni risposta."
"settingsContextHint": "Blocchi opzionali su cui il coach può basarsi. Disattivandone uno, viene omesso da ogni risposta.",
"cluster": {
"cardio": {
"label": "Cardiovascolare",
"hint": "Pressione · polso · HRV · FC riposo e cammino"
},
"body": {
"label": "Composizione corporea",
"hint": "Peso · grasso · muscoli · IMC"
},
"activity": {
"label": "Attività",
"hint": "Passi · energia · distanza · VO₂ max"
},
"workouts": {
"label": "Allenamenti",
"hint": "Corse · uscite · sessioni"
},
"sleep": {
"label": "Sonno",
"hint": "Durata · fasi"
},
"mood": {
"label": "Umore",
"hint": "Voci di umore giornaliere"
},
"glucose": {
"label": "Glicemia",
"hint": "Valori a digiuno e postprandiali"
},
"medication": {
"label": "Farmaci",
"hint": "Aderenza · contesto GLP-1"
},
"mobility": {
"label": "Mobilità e andatura",
"hint": "Stabilità · asimmetria · andatura"
},
"environment": {
"label": "Ambiente",
"hint": "Rumore · luce · temp. cutanea"
}
},
"settingsClustersLabel": "Dati che il Coach vede",
"settingsClustersHint": "Scegli quali gruppi di dati il Coach può leggere. Più dati significano risposte più ricche e un prompt più grande.",
"settingsClusterNoData": "Ancora nessun dato"
},
"relativeJustNow": "proprio ora",
"relativeMinutesAgoOne": "{count} minuto fa",
@@ -1657,6 +1754,30 @@
"vitalsParent": {
"label": "Vitali",
"header": "Parametri vitali"
},
"bodyParent": {
"label": "Corpo",
"header": "Composizione corporea"
},
"activityParent": {
"label": "Attività",
"header": "Attività e mobilità"
},
"cardiovascularParent": {
"label": "Cardiovascolare",
"header": "Marcatori cardiovascolari"
},
"hearingParent": {
"label": "Udito",
"header": "Udito ed esposizione sonora"
},
"environmentParent": {
"label": "Ambiente",
"header": "Ambiente"
},
"metabolicParent": {
"label": "Metabolico",
"header": "Metabolico"
}
},
"hrv": {
@@ -1859,7 +1980,247 @@
"mixedCardio": "Cardio misto",
"other": "Altro"
}
}
},
"bloodGlucose": {
"title": "Glicemia",
"description": "Le tue letture di glicemia nel tempo, in mg/dL.",
"chartTitle": "Glicemia",
"emptyState": {
"title": "Nessun dato di glicemia",
"description": "Le letture di glicemia appaiono qui quando ne registri una o un dispositivo si sincronizza."
}
},
"navBloodGlucose": "Glucosio",
"totalBodyWater": {
"title": "Acqua corporea totale",
"description": "La tua acqua corporea totale nel tempo, in chilogrammi.",
"chartTitle": "Acqua corporea totale",
"emptyState": {
"title": "Nessun dato di acqua corporea",
"description": "Le letture di acqua corporea appaiono qui quando una bilancia impedenziometrica si sincronizza."
}
},
"navTotalBodyWater": "Acqua corporea",
"boneMass": {
"title": "Massa ossea",
"description": "La tua massa ossea nel tempo, in chilogrammi.",
"chartTitle": "Massa ossea",
"emptyState": {
"title": "Nessun dato di massa ossea",
"description": "Le letture di massa ossea appaiono qui quando una bilancia impedenziometrica si sincronizza."
}
},
"navBoneMass": "Massa ossea",
"flightsClimbed": {
"title": "Piani saliti",
"description": "Rampe di scale salite ogni giorno.",
"chartTitle": "Piani saliti al giorno",
"emptyState": {
"title": "Nessun dato sui piani",
"description": "I totali dei piani appaiono qui quando Apple Health sincronizza una giornata."
}
},
"navFlightsClimbed": "Piani",
"walkingRunningDistance": {
"title": "Distanza camminata + corsa",
"description": "Distanza di cammino e corsa percorsa ogni giorno, in chilometri.",
"chartTitle": "Distanza al giorno",
"emptyState": {
"title": "Nessun dato di distanza",
"description": "La distanza giornaliera appare qui quando Apple Health sincronizza una giornata."
}
},
"navWalkingRunningDistance": "Distanza",
"fatFreeMass": {
"title": "Massa magra",
"description": "La tua massa magra nel tempo, in chilogrammi.",
"chartTitle": "Massa magra",
"emptyState": {
"title": "Nessun dato",
"description": "Le letture appaiono qui quando una bilancia impedenziometrica si sincronizza."
}
},
"navFatFreeMass": "Massa magra",
"fatMass": {
"title": "Massa grassa",
"description": "La tua massa grassa nel tempo, in chilogrammi.",
"chartTitle": "Massa grassa",
"emptyState": {
"title": "Nessun dato di massa grassa",
"description": "Le letture appaiono qui quando una bilancia impedenziometrica si sincronizza."
}
},
"navFatMass": "Massa grassa",
"muscleMass": {
"title": "Massa muscolare",
"description": "La tua massa muscolare nel tempo, in chilogrammi.",
"chartTitle": "Massa muscolare",
"emptyState": {
"title": "Nessun dato muscolare",
"description": "Le letture appaiono qui quando una bilancia impedenziometrica si sincronizza."
}
},
"navMuscleMass": "Massa muscolare",
"skinTemperature": {
"title": "Temperatura cutanea",
"description": "La temperatura cutanea del polso nel tempo, in gradi Celsius.",
"chartTitle": "Temperatura cutanea",
"emptyState": {
"title": "Nessun dato",
"description": "Le letture appaiono qui quando un dispositivo sincronizza la temperatura notturna."
}
},
"navSkinTemperature": "Temp. pelle",
"pulseWaveVelocity": {
"title": "Velocità dell’onda di polso",
"description": "La tua velocità dell’onda di polso arteriosa nel tempo, in m/s — valori più alti indicano arterie più rigide.",
"chartTitle": "Velocità dell’onda di polso",
"emptyState": {
"title": "Nessun dato",
"description": "Le letture appaiono qui quando un dispositivo Withings compatibile si sincronizza."
}
},
"navPulseWaveVelocity": "Velocità onda di polso",
"vascularAge": {
"title": "Età vascolare",
"description": "La tua età vascolare stimata nel tempo, in anni.",
"chartTitle": "Età vascolare",
"emptyState": {
"title": "Nessun dato",
"description": "Le stime appaiono qui quando un dispositivo Withings compatibile si sincronizza."
}
},
"navVascularAge": "Età vascolare",
"visceralFat": {
"title": "Grasso viscerale",
"description": "Il tuo indice di grasso viscerale nel tempo.",
"chartTitle": "Grasso viscerale",
"emptyState": {
"title": "Nessun dato",
"description": "Gli indici appaiono qui quando una bilancia impedenziometrica compatibile si sincronizza."
}
},
"navVisceralFat": "Grasso viscerale",
"audioExposureEnv": {
"title": "Audio ambientale",
"description": "La tua esposizione media giornaliera al suono ambientale, in dBA.",
"chartTitle": "Esposizione al suono ambientale",
"emptyState": {
"title": "Nessun dato",
"description": "I livelli sonori appaiono qui quando Apple Health si sincronizza."
}
},
"navAudioExposureEnv": "Audio ambientale",
"audioExposureHeadphone": {
"title": "Audio cuffie",
"description": "La tua esposizione media giornaliera con le cuffie, in dBA.",
"chartTitle": "Esposizione cuffie",
"emptyState": {
"title": "Nessun dato",
"description": "I livelli appaiono qui quando Apple Health si sincronizza."
}
},
"navAudioExposureHeadphone": "Audio cuffie",
"timeInDaylight": {
"title": "Tempo alla luce del giorno",
"description": "Minuti trascorsi alla luce del giorno ogni giorno.",
"chartTitle": "Luce diurna al giorno",
"emptyState": {
"title": "Nessun dato",
"description": "I totali appaiono qui quando Apple Health sincronizza una giornata."
}
},
"navTimeInDaylight": "Luce diurna",
"walkingSteadiness": {
"title": "Stabilità del cammino",
"description": "La tua stabilità del cammino nel tempo, in percentuale.",
"chartTitle": "Stabilità del cammino",
"emptyState": {
"title": "Nessun dato",
"description": "Le letture appaiono qui quando Apple Health si sincronizza."
}
},
"navWalkingSteadiness": "Stabilità del cammino",
"audioExposureEvent": {
"title": "Eventi audio forti",
"description": "Giorni con un evento di esposizione a rumore forte.",
"chartTitle": "Eventi rumorosi al giorno",
"emptyState": {
"title": "Nessun dato",
"description": "Gli eventi appaiono qui quando Apple Health si sincronizza."
}
},
"navAudioExposureEvent": "Eventi rumorosi",
"respiratoryRate": {
"title": "Frequenza respiratoria",
"description": "La tua frequenza respiratoria nel tempo, in respiri al minuto.",
"chartTitle": "Frequenza respiratoria",
"emptyState": {
"title": "Nessun dato",
"description": "Le letture appaiono qui quando Apple Health sincronizza la respirazione del sonno o dell’allenamento."
}
},
"navRespiratoryRate": "Frequenza respiratoria",
"leanBodyMass": {
"title": "Massa corporea magra",
"description": "La tua massa corporea magra nel tempo, in chilogrammi.",
"chartTitle": "Massa corporea magra",
"emptyState": {
"title": "Nessun dato",
"description": "Le letture appaiono qui quando una bilancia impedenziometrica si sincronizza."
}
},
"navLeanBodyMass": "Massa corporea magra",
"walkingHeartRateAverage": {
"title": "Battito camminando",
"description": "La tua frequenza cardiaca media camminando, in bpm.",
"chartTitle": "Battito medio camminando",
"emptyState": {
"title": "Nessun dato",
"description": "Le letture appaiono qui quando Apple Health si sincronizza."
}
},
"navWalkingHeartRateAverage": "Battito camminando",
"walkingAsymmetry": {
"title": "Asimmetria del cammino",
"description": "La tua asimmetria del cammino nel tempo, in percentuale.",
"chartTitle": "Asimmetria del cammino",
"emptyState": {
"title": "Nessun dato",
"description": "Le letture appaiono qui quando Apple Health si sincronizza."
}
},
"navWalkingAsymmetry": "Asimmetria del cammino",
"walkingDoubleSupport": {
"title": "Tempo di doppio appoggio",
"description": "La percentuale di ogni passo con entrambi i piedi a terra.",
"chartTitle": "Tempo di doppio appoggio",
"emptyState": {
"title": "Nessun dato",
"description": "Le letture appaiono qui quando Apple Health si sincronizza."
}
},
"navWalkingDoubleSupport": "Doppio appoggio",
"walkingStepLength": {
"title": "Lunghezza del passo",
"description": "La tua lunghezza media del passo nel tempo, in metri.",
"chartTitle": "Lunghezza del passo",
"emptyState": {
"title": "Nessun dato",
"description": "Le letture appaiono qui quando Apple Health si sincronizza."
}
},
"navWalkingStepLength": "Lunghezza del passo",
"walkingSpeed": {
"title": "Velocità di cammino",
"description": "La tua velocità media di cammino nel tempo, in km/h.",
"chartTitle": "Velocità di cammino (km/h)",
"emptyState": {
"title": "Nessun dato",
"description": "Le letture appaiono qui quando Apple Health si sincronizza."
}
},
"navWalkingSpeed": "Velocità di cammino"
},
"thresholds": {
"defaultLabel": "Predefinito",
@@ -2393,6 +2754,16 @@
"saveError": "Impossibile salvare la modifica. Riprova."
}
},
"dashboard": {
"units": {
"title": "Unità",
"description": "Scegli come vengono mostrate distanze e velocità nei grafici e nei riquadri.",
"metric": "Metrico",
"imperial": "Imperiale",
"saved": "Preferenza unità salvata.",
"saveError": "Impossibile salvare la preferenza delle unità."
}
},
"withings": "Withings",
"withingsDescription": "Connect your Withings scale and blood pressure monitors.",
"withingsCredentials": "API Credentials",
@@ -2559,6 +2930,54 @@
"nextRetry": "Next retry",
"reEnable": "Re-enable",
"sendTest": "Send test"
},
"identity": {
"description": "Dati facoltativi per l’esportazione della cartella clinica (copertina PDF + esportazione FHIR). Tutti i campi sono facoltativi.",
"fullName": "Nome completo",
"fullNamePlaceholder": "es. Maria Rossi",
"insurer": "Assicurazione sanitaria",
"insurerPlaceholder": "es. AOK Bayern",
"insuranceNumber": "Numero di assicurato (KVNR)",
"insuranceNumberHint": "10 caratteri: una lettera + nove cifre (es. A123456780)."
},
"healthRecord": {
"title": "Esporta cartella clinica",
"description": "Genera una cartella clinica completa per il medico — come PDF, strutturata (FHIR) o pacchetto combinato.",
"format": "Formato",
"format_pdf": "PDF",
"format_fhir": "Strutturato (FHIR)",
"format_package": "Pacchetto: PDF + FHIR",
"range": "Periodo",
"range90": "Ultimi 90 giorni",
"range180": "Ultimi 6 mesi",
"range365": "Ultimi 12 mesi",
"practiceName": "Studio (facoltativo)",
"practiceNamePlaceholder": "es. Dr. Rossi",
"includedData": "Dati inclusi",
"groupVitals": "Parametri vitali",
"groupCardio": "Cuore & fitness",
"groupActivity": "Attività & sonno",
"weight": "Peso",
"bp": "Pressione sanguigna",
"pulse": "Polso",
"oxygenSaturation": "Saturazione di ossigeno",
"bodyFat": "Massa grassa",
"bodyComposition": "Composizione corporea",
"restingHeartRate": "Frequenza a riposo",
"hrv": "Variabilità della frequenza cardiaca",
"vo2max": "VO₂max",
"steps": "Passi",
"distance": "Distanza",
"sleep": "Sonno",
"glucose": "Glicemia",
"medications": "Farmaci e aderenza",
"mood": "Umore (disattivato di default — dati sensibili)",
"bmi": "BMI",
"includeCharts": "Incorpora grafici di tendenza (solo PDF)",
"includeAiSummary": "Aggiungi riepilogo IA (non validato clinicamente)",
"fhirNote": "L’output strutturato segue lo standard FHIR R4 (compatibile ePA).",
"generate": "Genera",
"error": "Esportazione non riuscita ({code})."
}
},
"admin": {
@@ -3346,13 +3765,16 @@
},
"moodReminder": {
"title": "Promemoria quotidiano dell’umore",
"description": "Promemoria leggero alle 22:00 se non hai ancora registrato l’umore di oggi. Usa solo i canali già configurati (Telegram, ntfy, Web Push, Apple Push) e tace una volta registrato l’umore di oggi.",
"description": "Promemoria leggero all’orario che scegli se non hai ancora registrato l’umore di oggi. Usa solo i canali già configurati (Telegram, ntfy, Web Push, Apple Push) e tace una volta registrato l’umore di oggi.",
"toggleAria": "Attiva o disattiva il promemoria quotidiano dell’umore",
"statusOn": "Attivo alle 22:00 ora locale",
"statusOn": "Attivo all’orario che scegli",
"statusOff": "Disattivato",
"enabledToast": "Promemoria dell’umore attivato.",
"disabledToast": "Promemoria dell’umore disattivato.",
"saveError": "Salvataggio non riuscito. Riprova."
"saveError": "Salvataggio non riuscito. Riprova.",
"hourLabel": "Ora del promemoria",
"hourAria": "Ora del promemoria dell’umore",
"hourSavedToast": "Ora del promemoria aggiornata."
},
"eventMedicationReminder": "Promemoria farmaco",
"eventMedicationReminderDesc": "Inviato a ogni orario di dose programmato.",
+435 -13
View File
@@ -148,7 +148,17 @@
"sleep": "Czas snu",
"empty": "Brak danych dla wybranego okresu. Raport zachowa stronę tytułową.",
"unavailableHint": "Brak danych w tym okresie"
}
},
"insurer": "Ubezpieczyciel zdrowotny",
"insuranceNumber": "Nr ubezpieczonego",
"summaryTitle": "Podsumowanie kliniczne",
"summaryReadings": "Zarejestrowano w ciągu {days} dni: {count} pomiarów w {params} parametrach.",
"summaryTrend": "{label}: ostatnio {latest} {arrow}",
"summaryAdherence": "Przestrzeganie zaleceń lekowych: {rate}% (średnia ważona).",
"chartsTitle": "Wykresy trendów",
"aiSummaryTitle": "Podsumowanie AI — niezweryfikowane klinicznie",
"aiSummaryDisclaimer": "Ta sekcja została wygenerowana automatycznie przez model AI i nie jest zweryfikowana klinicznie. Nie zastępuje oceny lekarskiej.",
"packageReadme": "Dokumentacja zdrowotna HealthLog\n\nTen pakiet zawiera:\n- report.pdf: raport zdrowotny dla lekarza.\n- bundle.json: te same dane jako dokument HL7 FHIR R4 (Bundle), zgodny z ePA.\n\nWygenerowano w HealthLog."
},
"errorBoundary": {
"title": "Coś poszło nie tak",
@@ -1150,7 +1160,6 @@
"intake": {
"title": "Intake history",
"importButton": "Import",
"viewAllLink": "View full history →",
"rowActions": {
"openMenu": "Open row actions",
"edit": "Edit",
@@ -1199,10 +1208,6 @@
},
"settings": {
"title": "Settings",
"csvImport": {
"title": "CSV import",
"stub": "Import intakes directly from the history above."
},
"phases": {
"openButton": "Configure phases",
"requiresCourseWindow": "Phases are available once a course window is set."
@@ -1273,7 +1278,28 @@
"advancedOption": "Ustawienia zaawansowane"
},
"advanced": {
"title": "Ustawienia zaawansowane"
"title": "Ustawienia zaawansowane",
"group": {
"data": "Dane",
"reminders": "Przypomnienia",
"lifecycle": "Cykl życia",
"danger": "Strefa zagrożenia"
},
"csvImport": {
"title": "Import CSV/JSON",
"helper": "Importuj wcześniejsze przyjęcia z pliku lub wklejonego JSON.",
"button": "Importuj CSV/JSON"
}
},
"header": {
"historyLabel": "Zobacz historię przyjęć",
"advancedLabel": "Ustawienia zaawansowane"
},
"history": {
"back": "Powrót do leku",
"subtitle": "Pełna historia przyjęć",
"plannedSuffix": "zaplanowane",
"drugLevelDisclosure": "Pokaż szacowaną krzywą stężenia leku"
}
}
},
@@ -1592,7 +1618,33 @@
"vo2_max": "VO₂ max",
"body_temp": "Temperatura ciała",
"medications": "Leki",
"anthropometrics": "Profil (wzrost, wiek, płeć)"
"anthropometrics": "Profil (wzrost, wiek, płeć)",
"walking_hr": "Tętno podczas chodzenia",
"respiratory_rate": "Częstość oddechów",
"spo2": "Saturacja krwi",
"pulse_wave_velocity": "Prędkość fali tętna",
"vascular_age": "Wiek naczyniowy",
"body_fat": "Tkanka tłuszczowa",
"fat_mass": "Masa tłuszczowa",
"fat_free_mass": "Masa beztłuszczowa",
"muscle_mass": "Masa mięśniowa",
"lean_body_mass": "Beztłuszczowa masa ciała",
"bone_mass": "Masa kostna",
"total_body_water": "Woda w organizmie",
"bmi": "BMI",
"visceral_fat": "Tłuszcz trzewny",
"glucose": "Glukoza",
"walking_steadiness": "Stabilność chodu",
"walking_asymmetry": "Asymetria chodu",
"walking_double_support": "Czas podwójnego podparcia",
"walking_step_length": "Długość kroku",
"walking_speed": "Prędkość chodu",
"audio_env": "Dźwięk otoczenia",
"audio_headphone": "Dźwięk słuchawek",
"audio_event": "Zdarzenia głośnego dźwięku",
"daylight": "Czas w świetle dziennym",
"skin_temp": "Temperatura skóry",
"workouts": "Treningi"
},
"window": {
"last7days": "Ostatnie 7 dni",
@@ -1630,7 +1682,52 @@
"providerRateLimitTitle": "Dostawca z ograniczeniem",
"providerRateLimitBody": "Dostawca jest tymczasowo przeciążony; reset za ~5 min.",
"settingsContextLabel": "Wysyłaj kontekst",
"settingsContextHint": "Opcjonalne bloki, na których coach może się opierać. Wyłączenie pominie blok w każdej odpowiedzi."
"settingsContextHint": "Opcjonalne bloki, na których coach może się opierać. Wyłączenie pominie blok w każdej odpowiedzi.",
"cluster": {
"cardio": {
"label": "Sercowo-naczyniowe",
"hint": "Ciśnienie · tętno · HRV · tętno spoczynkowe i marszowe"
},
"body": {
"label": "Skład ciała",
"hint": "Waga · tłuszcz · mięśnie · BMI"
},
"activity": {
"label": "Aktywność",
"hint": "Kroki · energia · dystans · VO₂ max"
},
"workouts": {
"label": "Treningi",
"hint": "Biegi · przejażdżki · sesje"
},
"sleep": {
"label": "Sen",
"hint": "Czas trwania · fazy"
},
"mood": {
"label": "Nastrój",
"hint": "Codzienne wpisy nastroju"
},
"glucose": {
"label": "Glukoza",
"hint": "Pomiary na czczo i poposiłkowe"
},
"medication": {
"label": "Leki",
"hint": "Przestrzeganie · kontekst GLP-1"
},
"mobility": {
"label": "Mobilność i chód",
"hint": "Stabilność · asymetria · chód"
},
"environment": {
"label": "Środowisko",
"hint": "Hałas · światło · temp. skóry"
}
},
"settingsClustersLabel": "Dane widziane przez Coacha",
"settingsClustersHint": "Wybierz, które grupy danych Coach może czytać. Więcej danych to bogatsze odpowiedzi i większy prompt.",
"settingsClusterNoData": "Brak danych"
},
"relativeJustNow": "przed chwilą",
"relativeMinutesAgoOne": "{count} minutę temu",
@@ -1657,6 +1754,30 @@
"vitalsParent": {
"label": "Witalne",
"header": "Parametry witalne"
},
"bodyParent": {
"label": "Ciało",
"header": "Skład ciała"
},
"activityParent": {
"label": "Aktywność",
"header": "Aktywność i mobilność"
},
"cardiovascularParent": {
"label": "Sercowo-naczyniowe",
"header": "Markery sercowo-naczyniowe"
},
"hearingParent": {
"label": "Słuch",
"header": "Słuch i ekspozycja na dźwięk"
},
"environmentParent": {
"label": "Środowisko",
"header": "Środowisko"
},
"metabolicParent": {
"label": "Metaboliczne",
"header": "Metaboliczne"
}
},
"hrv": {
@@ -1859,7 +1980,247 @@
"mixedCardio": "Cardio mieszane",
"other": "Inne"
}
}
},
"bloodGlucose": {
"title": "Poziom glukozy",
"description": "Twoje odczyty glukozy w czasie, w mg/dL.",
"chartTitle": "Poziom glukozy",
"emptyState": {
"title": "Brak danych o glukozie",
"description": "Odczyty glukozy pojawią się tutaj po zapisaniu jednego lub synchronizacji urządzenia."
}
},
"navBloodGlucose": "Glukoza",
"totalBodyWater": {
"title": "Całkowita woda w organizmie",
"description": "Twoja całkowita woda w organizmie w czasie, w kilogramach.",
"chartTitle": "Całkowita woda w organizmie",
"emptyState": {
"title": "Brak danych o wodzie",
"description": "Odczyty wody pojawią się tutaj po synchronizacji wagi analitycznej."
}
},
"navTotalBodyWater": "Woda w organizmie",
"boneMass": {
"title": "Masa kostna",
"description": "Twoja masa kostna w czasie, w kilogramach.",
"chartTitle": "Masa kostna",
"emptyState": {
"title": "Brak danych o masie kostnej",
"description": "Odczyty masy kostnej pojawią się tutaj po synchronizacji wagi analitycznej."
}
},
"navBoneMass": "Masa kostna",
"flightsClimbed": {
"title": "Pokonane piętra",
"description": "Kondygnacje schodów pokonane każdego dnia.",
"chartTitle": "Pokonane piętra dziennie",
"emptyState": {
"title": "Brak danych o piętrach",
"description": "Sumy pięter pojawią się tutaj po synchronizacji dnia przez Apple Health."
}
},
"navFlightsClimbed": "Piętra",
"walkingRunningDistance": {
"title": "Dystans marsz + bieg",
"description": "Dystans marszu i biegu pokonany każdego dnia, w kilometrach.",
"chartTitle": "Dystans dziennie",
"emptyState": {
"title": "Brak danych o dystansie",
"description": "Dzienny dystans pojawi się tutaj po synchronizacji dnia przez Apple Health."
}
},
"navWalkingRunningDistance": "Dystans",
"fatFreeMass": {
"title": "Masa beztłuszczowa",
"description": "Twoja masa beztłuszczowa w czasie, w kilogramach.",
"chartTitle": "Masa beztłuszczowa",
"emptyState": {
"title": "Brak danych",
"description": "Odczyty pojawią się tutaj po synchronizacji wagi analitycznej."
}
},
"navFatFreeMass": "Masa beztłuszczowa",
"fatMass": {
"title": "Masa tłuszczowa",
"description": "Twoja masa tłuszczowa w czasie, w kilogramach.",
"chartTitle": "Masa tłuszczowa",
"emptyState": {
"title": "Brak danych",
"description": "Odczyty pojawią się tutaj po synchronizacji wagi analitycznej."
}
},
"navFatMass": "Masa tłuszczowa",
"muscleMass": {
"title": "Masa mięśniowa",
"description": "Twoja masa mięśniowa w czasie, w kilogramach.",
"chartTitle": "Masa mięśniowa",
"emptyState": {
"title": "Brak danych",
"description": "Odczyty pojawią się tutaj po synchronizacji wagi analitycznej."
}
},
"navMuscleMass": "Masa mięśniowa",
"skinTemperature": {
"title": "Temperatura skóry",
"description": "Temperatura skóry nadgarstka w czasie, w stopniach Celsjusza.",
"chartTitle": "Temperatura skóry",
"emptyState": {
"title": "Brak danych",
"description": "Odczyty pojawią się tutaj po synchronizacji nocnej temperatury przez urządzenie."
}
},
"navSkinTemperature": "Temp. skóry",
"pulseWaveVelocity": {
"title": "Prędkość fali tętna",
"description": "Prędkość fali tętna tętniczego w czasie, w m/s — wyższe wartości sugerują sztywniejsze tętnice.",
"chartTitle": "Prędkość fali tętna",
"emptyState": {
"title": "Brak danych",
"description": "Odczyty pojawią się tutaj po synchronizacji zgodnego urządzenia Withings."
}
},
"navPulseWaveVelocity": "Prędkość fali tętna",
"vascularAge": {
"title": "Wiek naczyniowy",
"description": "Twój szacowany wiek naczyniowy w czasie, w latach.",
"chartTitle": "Wiek naczyniowy",
"emptyState": {
"title": "Brak danych",
"description": "Szacunki pojawią się tutaj po synchronizacji zgodnego urządzenia Withings."
}
},
"navVascularAge": "Wiek naczyniowy",
"visceralFat": {
"title": "Tłuszcz trzewny",
"description": "Twój wskaźnik tłuszczu trzewnego w czasie.",
"chartTitle": "Tłuszcz trzewny",
"emptyState": {
"title": "Brak danych",
"description": "Wskaźniki pojawią się tutaj po synchronizacji zgodnej wagi analitycznej."
}
},
"navVisceralFat": "Tłuszcz trzewny",
"audioExposureEnv": {
"title": "Hałas otoczenia",
"description": "Twoja średnia dzienna ekspozycja na dźwięk otoczenia, w dBA.",
"chartTitle": "Ekspozycja na dźwięk otoczenia",
"emptyState": {
"title": "Brak danych",
"description": "Poziomy dźwięku pojawią się tutaj po synchronizacji Apple Health."
}
},
"navAudioExposureEnv": "Hałas otoczenia",
"audioExposureHeadphone": {
"title": "Dźwięk słuchawek",
"description": "Twoja średnia dzienna ekspozycja na dźwięk słuchawek, w dBA.",
"chartTitle": "Ekspozycja słuchawek",
"emptyState": {
"title": "Brak danych",
"description": "Poziomy pojawią się tutaj po synchronizacji Apple Health."
}
},
"navAudioExposureHeadphone": "Dźwięk słuchawek",
"timeInDaylight": {
"title": "Czas w świetle dziennym",
"description": "Minuty spędzone w świetle dziennym każdego dnia.",
"chartTitle": "Światło dzienne dziennie",
"emptyState": {
"title": "Brak danych",
"description": "Sumy pojawią się tutaj po synchronizacji dnia przez Apple Health."
}
},
"navTimeInDaylight": "Światło dzienne",
"walkingSteadiness": {
"title": "Stabilność chodu",
"description": "Twoja stabilność chodu w czasie, w procentach.",
"chartTitle": "Stabilność chodu",
"emptyState": {
"title": "Brak danych",
"description": "Odczyty pojawią się tutaj po synchronizacji Apple Health."
}
},
"navWalkingSteadiness": "Stabilność chodu",
"audioExposureEvent": {
"title": "Głośne zdarzenia dźwiękowe",
"description": "Dni ze zdarzeniem ekspozycji na głośny hałas.",
"chartTitle": "Zdarzenia hałasu dziennie",
"emptyState": {
"title": "Brak danych",
"description": "Zdarzenia pojawią się tutaj po synchronizacji Apple Health."
}
},
"navAudioExposureEvent": "Zdarzenia hałasu",
"respiratoryRate": {
"title": "Częstość oddechów",
"description": "Twoja częstość oddechów w czasie, w oddechach na minutę.",
"chartTitle": "Częstość oddechów",
"emptyState": {
"title": "Brak danych",
"description": "Odczyty pojawią się tutaj po synchronizacji oddechu snu lub treningu przez Apple Health."
}
},
"navRespiratoryRate": "Częstość oddechów",
"leanBodyMass": {
"title": "Beztłuszczowa masa ciała",
"description": "Twoja beztłuszczowa masa ciała w czasie, w kilogramach.",
"chartTitle": "Beztłuszczowa masa ciała",
"emptyState": {
"title": "Brak danych",
"description": "Odczyty pojawią się tutaj po synchronizacji wagi analitycznej."
}
},
"navLeanBodyMass": "Beztłuszczowa masa ciała",
"walkingHeartRateAverage": {
"title": "Tętno podczas chodu",
"description": "Twoje średnie tętno podczas chodzenia, w ud./min.",
"chartTitle": "Średnie tętno podczas chodu",
"emptyState": {
"title": "Brak danych",
"description": "Odczyty pojawią się tutaj po synchronizacji Apple Health."
}
},
"navWalkingHeartRateAverage": "Tętno podczas chodu",
"walkingAsymmetry": {
"title": "Asymetria chodu",
"description": "Twoja asymetria chodu w czasie, w procentach.",
"chartTitle": "Asymetria chodu",
"emptyState": {
"title": "Brak danych",
"description": "Odczyty pojawią się tutaj po synchronizacji Apple Health."
}
},
"navWalkingAsymmetry": "Asymetria chodu",
"walkingDoubleSupport": {
"title": "Czas podwójnego podparcia",
"description": "Odsetek każdego kroku z obiema stopami na ziemi.",
"chartTitle": "Czas podwójnego podparcia",
"emptyState": {
"title": "Brak danych",
"description": "Odczyty pojawią się tutaj po synchronizacji Apple Health."
}
},
"navWalkingDoubleSupport": "Podwójne podparcie",
"walkingStepLength": {
"title": "Długość kroku",
"description": "Twoja średnia długość kroku w czasie, w metrach.",
"chartTitle": "Długość kroku",
"emptyState": {
"title": "Brak danych",
"description": "Odczyty pojawią się tutaj po synchronizacji Apple Health."
}
},
"navWalkingStepLength": "Długość kroku",
"walkingSpeed": {
"title": "Prędkość chodu",
"description": "Twoja średnia prędkość chodu w czasie, w km/h.",
"chartTitle": "Prędkość chodu (km/h)",
"emptyState": {
"title": "Brak danych",
"description": "Odczyty pojawią się tutaj po synchronizacji Apple Health."
}
},
"navWalkingSpeed": "Prędkość chodu"
},
"thresholds": {
"defaultLabel": "Domyślne",
@@ -2393,6 +2754,16 @@
"saveError": "Nie udało się zapisać zmiany. Spróbuj ponownie."
}
},
"dashboard": {
"units": {
"title": "Jednostki",
"description": "Wybierz sposób wyświetlania odległości i prędkości na wykresach i kafelkach.",
"metric": "Metryczne",
"imperial": "Imperialne",
"saved": "Zapisano preferencję jednostek.",
"saveError": "Nie udało się zapisać preferencji jednostek."
}
},
"withings": "Withings",
"withingsDescription": "Connect your Withings scale and blood pressure monitors.",
"withingsCredentials": "API Credentials",
@@ -2559,6 +2930,54 @@
"nextRetry": "Next retry",
"reEnable": "Re-enable",
"sendTest": "Send test"
},
"identity": {
"description": "Opcjonalne dane do eksportu dokumentacji zdrowotnej (okładka PDF + eksport FHIR). Wszystkie pola są opcjonalne.",
"fullName": "Imię i nazwisko",
"fullNamePlaceholder": "np. Anna Kowalska",
"insurer": "Ubezpieczyciel zdrowotny",
"insurerPlaceholder": "np. AOK Bayern",
"insuranceNumber": "Numer ubezpieczonego (KVNR)",
"insuranceNumberHint": "10 znaków: jedna litera + dziewięć cyfr (np. A123456780)."
},
"healthRecord": {
"title": "Eksportuj dokumentację zdrowotną",
"description": "Wygeneruj pełną dokumentację zdrowotną dla lekarza — jako PDF, ustrukturyzowaną (FHIR) lub łączny pakiet.",
"format": "Format",
"format_pdf": "PDF",
"format_fhir": "Ustrukturyzowany (FHIR)",
"format_package": "Pakiet: PDF + FHIR",
"range": "Okres",
"range90": "Ostatnie 90 dni",
"range180": "Ostatnie 6 miesięcy",
"range365": "Ostatnie 12 miesięcy",
"practiceName": "Gabinet (opcjonalnie)",
"practiceNamePlaceholder": "np. dr Kowalski",
"includedData": "Uwzględnione dane",
"groupVitals": "Parametry życiowe",
"groupCardio": "Serce i kondycja",
"groupActivity": "Aktywność i sen",
"weight": "Waga",
"bp": "Ciśnienie krwi",
"pulse": "Tętno",
"oxygenSaturation": "Saturacja tlenem",
"bodyFat": "Tkanka tłuszczowa",
"bodyComposition": "Skład ciała",
"restingHeartRate": "Tętno spoczynkowe",
"hrv": "Zmienność rytmu serca",
"vo2max": "VO₂max",
"steps": "Kroki",
"distance": "Dystans",
"sleep": "Sen",
"glucose": "Glukoza we krwi",
"medications": "Leki i przestrzeganie zaleceń",
"mood": "Nastrój (domyślnie wyłączony — dane wrażliwe)",
"bmi": "BMI",
"includeCharts": "Osadź wykresy trendów (tylko PDF)",
"includeAiSummary": "Dołącz podsumowanie AI (niezweryfikowane klinicznie)",
"fhirNote": "Dane ustrukturyzowane są zgodne ze standardem FHIR R4 (kompatybilne z ePA).",
"generate": "Generuj",
"error": "Eksport nie powiódł się ({code})."
}
},
"admin": {
@@ -3346,13 +3765,16 @@
},
"moodReminder": {
"title": "Codzienne przypomnienie o nastroju",
"description": "Delikatne przypomnienie o 22:00, jeśli dziś jeszcze nie zapisano nastroju. Wysyłane tylko skonfigurowanymi już kanałami (Telegram, ntfy, Web Push, Apple Push) i milknie, gdy dziś zapisano nastrój.",
"description": "Delikatne przypomnienie o wybranej przez Ciebie godzinie, jeśli dziś jeszcze nie zapisano nastroju. Wysyłane tylko skonfigurowanymi już kanałami (Telegram, ntfy, Web Push, Apple Push) i milknie, gdy dziś zapisano nastrój.",
"toggleAria": "Włącz lub wyłącz codzienne przypomnienie o nastroju",
"statusOn": "Aktywne o 22:00 czasu lokalnego",
"statusOn": "Aktywne o wybranej godzinie",
"statusOff": "Wyłączone",
"enabledToast": "Przypomnienie o nastroju włączone.",
"disabledToast": "Przypomnienie o nastroju wyłączone.",
"saveError": "Zapis nieudany. Spróbuj ponownie."
"saveError": "Zapis nieudany. Spróbuj ponownie.",
"hourLabel": "Godzina przypomnienia",
"hourAria": "Godzina przypomnienia o nastroju",
"hourSavedToast": "Godzina przypomnienia zaktualizowana."
},
"eventMedicationReminder": "Przypomnienie o leku",
"eventMedicationReminderDesc": "Wysyłane o każdej zaplanowanej godzinie dawki.",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.6.0",
"version": "1.7.0",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "AGPL-3.0-only",
"homepage": "https://healthlog.dev",
@@ -65,6 +65,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dotenv": "^17.4.2",
"fflate": "^0.8.3",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.8",
"lucide-react": "^1.16.0",
+7 -4
View File
@@ -56,6 +56,9 @@ importers:
dotenv:
specifier: ^17.4.2
version: 17.4.2
fflate:
specifier: ^0.8.3
version: 0.8.3
jspdf:
specifier: ^4.2.1
version: 4.2.1
@@ -4062,8 +4065,8 @@ packages:
resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
engines: {node: ^12.20 || >= 14.13}
fflate@0.8.2:
resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
fflate@0.8.3:
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
figures@6.1.0:
resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==}
@@ -10540,7 +10543,7 @@ snapshots:
node-domexception: 1.0.0
web-streams-polyfill: 3.3.3
fflate@0.8.2: {}
fflate@0.8.3: {}
figures@6.1.0:
dependencies:
@@ -11076,7 +11079,7 @@ snapshots:
dependencies:
'@babel/runtime': 7.29.2
fast-png: 6.4.0
fflate: 0.8.2
fflate: 0.8.3
optionalDependencies:
canvg: 3.0.11
core-js: 3.49.0
@@ -0,0 +1,30 @@
-- v1.7.0 — per-medication iOS reminder flags + per-device delivery override.
--
-- Three additive columns, no data step:
--
-- * medications.live_activity_enabled — iOS Live Activity opt-in for a
-- medication's reminders. The iOS client owns the ActivityKit
-- lifecycle; the server only stores and echoes the flag. Backfills
-- false via the column default.
--
-- * medications.critical_alarm_enabled — iOS 26 AlarmKit critical
-- reminder opt-in. Critical alarms bypass the mute switch / Focus on
-- the device; the server stores the preference only and hangs no
-- server-side behaviour off it. Backfills false via the default.
--
-- * devices.medication_delivery — per-device delivery override. NULL =
-- inherit the user-level roaming default
-- (User.notificationPrefs.medication.deliveryDefault). "server" forces
-- server APNs for this device; "client" forces local. v1.7.0 stores +
-- echoes this; cron suppression stays user-level (APNs fans out to all
-- devices and iOS dedupes locally).
--
-- All three defaults / NULLs are constants, so the ADD COLUMNs are
-- single non-blocking metadata operations on Postgres 11+.
ALTER TABLE "medications"
ADD COLUMN "live_activity_enabled" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "critical_alarm_enabled" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "devices"
ADD COLUMN "medication_delivery" TEXT;
@@ -0,0 +1,26 @@
-- v1.7.0 — medication schedule-type discriminator + cyclic on/off weeks.
--
-- Adds a Prisma enum `medication_schedule_type` (matching the
-- `medication_delivery_form` precedent — DB-level validation rather than
-- a free TEXT column) plus three columns on `medication_schedules`:
--
-- * schedule_type — SCHEDULED (default; rrule / rolling / legacy cadence
-- as today), PRN (as-needed: never projected, never reminded, excluded
-- from compliance expected-count, still loggable), CYCLIC (N weeks on /
-- M weeks off from the anchor; within an on-week the rrule / legacy
-- cadence applies as usual).
--
-- * cyclic_on_weeks / cyclic_off_weeks — only meaningful when
-- schedule_type = CYCLIC. The phase repeats (on, then off) from the
-- medication's startsOn ?? createdAt anchor.
--
-- Every existing row backfills to SCHEDULED via the column default and
-- NULL cyclic weeks, so the ALTERs are non-blocking metadata operations
-- on Postgres 11+ (the default is a constant).
CREATE TYPE "medication_schedule_type" AS ENUM ('SCHEDULED', 'PRN', 'CYCLIC');
ALTER TABLE "medication_schedules"
ADD COLUMN "schedule_type" "medication_schedule_type" NOT NULL DEFAULT 'SCHEDULED',
ADD COLUMN "cyclic_on_weeks" INTEGER,
ADD COLUMN "cyclic_off_weeks" INTEGER;
@@ -0,0 +1,18 @@
-- v1.7.0 — optional patient-identity profile fields for the health-record export.
--
-- Surfaced on the doctor-handover PDF cover and the FHIR `Patient` resource:
-- * full_name — legal patient name (plaintext, like date_of_birth/gender)
-- * insurer_name — health-insurer display name (plaintext)
-- * insurance_number_encrypted — German KVNR, a quasi-identifier, stored
-- AES-256-GCM encrypted under the active
-- ENCRYPTION_KEYS entry (see src/lib/crypto.ts).
--
-- All three are nullable with no default, so each ADD COLUMN is a single
-- non-blocking metadata operation on Postgres 11+. Existing rows read NULL,
-- which the export cover + FHIR builder collapse exactly like an absent
-- practice name.
ALTER TABLE "users"
ADD COLUMN "full_name" TEXT,
ADD COLUMN "insurer_name" TEXT,
ADD COLUMN "insurance_number_encrypted" TEXT;
@@ -0,0 +1,29 @@
-- v1.7.0 unit pref — global metric/imperial display preference.
--
-- Canonical storage stays SI on every measurement row. This column
-- only selects which branch of the display-time transform registry
-- (`src/lib/measurements/display-transform.ts`) the client renders:
-- km/h vs mph, km vs mi. No measurement value is rewritten.
--
-- Single nullable text column. Null = the application resolver reads
-- `DEFAULT_UNIT_PREFERENCE` ("metric"), matching the established
-- single-purpose display-unit precedent on the same table
-- (`glucose_unit`, which stays mg/dL canonical and switches mg/dL vs
-- mmol/L at display only). No JSON settings bag exists on `users`, so
-- a dedicated scalar mirrors `glucose_unit` rather than abusing one of
-- the domain-specific JSON columns (thresholds / dashboard / insights
-- layout / healthkit config).
--
-- Idempotent guard (`IF NOT EXISTS`) matches the 0084 / 0085 pattern
-- so reruns are safe where the migration already applied.
--
-- Reversibility: down migration is
-- ALTER TABLE "users" DROP COLUMN IF EXISTS "unit_preference";
-- A roll-back loses the saved preference (users fall back to metric);
-- the application reads tolerate a missing column / null row.
--
-- No index — reads at request time are per-user (`WHERE id = $1`),
-- matching `glucose_unit` and every other display-pref column.
ALTER TABLE "users"
ADD COLUMN IF NOT EXISTS "unit_preference" TEXT;
@@ -0,0 +1,20 @@
-- v1.7.0 — per-time-of-day reminder dispatch dedup.
--
-- The reminder worker dispatches one reminder per (medication, schedule,
-- day, phase). With first-class `times_of_day` (multiple per day) a
-- schedule like `["08:00","20:00"]` must dispatch TWICE a day, not once.
-- The dedup ledger therefore needs the time-of-day in its key.
--
-- Add `time_of_day` (TEXT, default '') and re-key the unique index to
-- include it. Existing rows backfill to '' via the default, which keeps
-- the pre-v1.7 single-window behaviour byte-stable: a single-time
-- schedule mints `time_of_day = ''` (or its lone HH:mm) and dedupes
-- exactly as before.
ALTER TABLE "telegram_reminder_messages"
ADD COLUMN "time_of_day" TEXT NOT NULL DEFAULT '';
DROP INDEX IF EXISTS "telegram_reminder_messages_medication_id_schedule_id_date_phase_key";
CREATE UNIQUE INDEX "telegram_reminder_messages_med_sched_date_phase_tod_key"
ON "telegram_reminder_messages" ("medication_id", "schedule_id", "date", "phase", "time_of_day");
@@ -0,0 +1,33 @@
-- v1.7.0 — supporting index for the `/api/sync/changes` delta feed.
--
-- The measurements delta feed keyset-walks `prisma.measurement.findMany`
-- filtered on `user_id` with `ORDER BY updated_at ASC, id ASC` and
-- `take limit+1` (src/app/api/sync/changes/route.ts). The existing
-- indexes on `measurements` cover `(user_id, type, measured_at)` and
-- `(external_id)` — neither serves the `(user_id, updated_at, id)`
-- order. Without this index Postgres filters by `user_id` then sorts
-- the user's entire measurement set in memory on every page, and the
-- route allows 120 pulls/min/user, so a multi-device offline drain on a
-- heavy Apple-Health tenant becomes a per-user full sort per page.
--
-- The composite `(user_id, updated_at, id)` index lets the keyset
-- pagination read straight off the index in order, with `id` as the
-- tiebreaker for same-millisecond `updated_at` rows.
--
-- Additive + order-safe: a pure `CREATE INDEX IF NOT EXISTS`, no column
-- add, no backfill, no rewrite of `measurements`.
--
-- Lock note: this is a plain, non-`CONCURRENTLY` index build, so it runs
-- inside Prisma's migration transaction and holds an ACCESS EXCLUSIVE
-- lock on `measurements` for the build's duration. The index covers
-- every row (no partial predicate), so on the largest multi-year
-- HealthKit tenants the build is the slowest of the v1.7.0 set; run the
-- deploy at a low-traffic window on such deployments. A `CONCURRENTLY`
-- build cannot run inside the migration transaction; a plain build is
-- acceptable at this table size in the maintenance window.
--
-- Reversibility: down migration is
-- DROP INDEX IF EXISTS "measurements_user_id_updated_at_id_idx";
CREATE INDEX IF NOT EXISTS "measurements_user_id_updated_at_id_idx"
ON "measurements" ("user_id", "updated_at", "id");
@@ -0,0 +1,50 @@
-- v1.7.0 sync — extend the offline-sync feed to mood + medication intakes.
--
-- Adds the same reconciliation columns the measurement feed already
-- carries (`sync_version` LWW counter + `deleted_at` soft-delete
-- tombstone) to `mood_entries` and `medication_intake_events`, plus an
-- `updated_at` column to `medication_intake_events` (which had none) so
-- the `/api/sync/changes` keyset feed can order on it. `mood_entries`
-- already has `updated_at`, so it only gains the two sync columns.
--
-- Conflict semantics mirror the per-domain rules in the iOS-coord
-- contract: mood is last-writer-wins by `sync_version`; an intake is an
-- immutable fact whose correction is a tombstone + re-insert, so its
-- `sync_version` only bumps on the soft-delete write.
--
-- Every add is additive + order-safe:
-- - `sync_version` defaults to 0 (constant) → metadata-only ADD COLUMN.
-- - `deleted_at` is nullable, no default → metadata-only.
-- - `updated_at` on intakes is NOT NULL with `DEFAULT now()` so the
-- ADD COLUMN backfills existing rows to their migration instant
-- without a table rewrite (constant-folded default on PG 11+). The
-- Prisma `@updatedAt` directive maintains it from the app layer on
-- every subsequent write; the DB default only seeds legacy rows.
--
-- Supporting keyset indexes match the measurement feed's
-- `(user_id, updated_at, id)` index from migration 0096 so the delta
-- drain reads straight off the index in order rather than sorting the
-- user's whole set per page.
--
-- Reversibility (down):
-- DROP INDEX IF EXISTS "medication_intake_events_user_id_updated_at_id_idx";
-- DROP INDEX IF EXISTS "mood_entries_user_id_updated_at_id_idx";
-- ALTER TABLE "medication_intake_events"
-- DROP COLUMN "deleted_at", DROP COLUMN "sync_version", DROP COLUMN "updated_at";
-- ALTER TABLE "mood_entries"
-- DROP COLUMN "deleted_at", DROP COLUMN "sync_version";
ALTER TABLE "mood_entries"
ADD COLUMN "sync_version" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "deleted_at" TIMESTAMP(3);
ALTER TABLE "medication_intake_events"
ADD COLUMN "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "sync_version" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "deleted_at" TIMESTAMP(3);
CREATE INDEX IF NOT EXISTS "mood_entries_user_id_updated_at_id_idx"
ON "mood_entries" ("user_id", "updated_at", "id");
CREATE INDEX IF NOT EXISTS "medication_intake_events_user_id_updated_at_id_idx"
ON "medication_intake_events" ("user_id", "updated_at", "id");
+107 -1
View File
@@ -82,6 +82,10 @@ model User {
insightsLayoutJson Json? @map("insights_layout_json")
// Blood-glucose display unit preference. Canonical storage is mg/dL.
glucoseUnit String? @map("glucose_unit") // "mg/dL" | "mmol/L" | null = default "mg/dL"
// v1.7.0 unit pref — global metric/imperial display preference.
// Canonical storage stays SI; this only selects the display-time
// transform branch (km/h vs mph, km vs mi). Null = default "metric".
unitPreference String? @map("unit_preference") // "metric" | "imperial" | null = default "metric"
// AI provider selection for insights. Null = OPENAI fallback.
aiProvider String? @map("ai_provider") // "OPENAI" | "ANTHROPIC" | "LOCAL" | "CHATGPT_OAUTH"
aiModel String? @map("ai_model")
@@ -394,6 +398,19 @@ model User {
// `reminder-worker.ts`).
pushAttempts PushAttempt[]
// v1.7.0 profile — optional patient-identity fields surfaced on the
// health-record export cover (PDF) and the FHIR `Patient` resource.
// `fullName` + `insurerName` are plaintext (consistent with the other
// plaintext profile columns: dateOfBirth, gender, heightCm). The German
// KVNR (`insuranceNumber`) is a quasi-identifier, so it is stored
// AES-256-GCM encrypted under the active key via `src/lib/crypto.ts`
// (column suffix `*Encrypted` per the project convention). All three are
// optional — an unset field collapses its cover line / FHIR slot exactly
// like the existing `practiceName`.
fullName String? @map("full_name")
insurerName String? @map("insurer_name")
insuranceNumberEncrypted String? @map("insurance_number_encrypted")
@@map("users")
}
@@ -610,6 +627,12 @@ model Measurement {
@@unique([userId, type, source, externalId])
@@index([userId, type, measuredAt])
@@index([externalId])
// v1.7.0 — supports the `/api/sync/changes` keyset feed, which walks
// `where userId` ordered by `(updatedAt asc, id asc)` with `take
// limit+1`. Without this index Postgres filters by `userId` then sorts
// the user's entire measurement set per page; the delta drain allows
// 120 pulls/min/user. See migration 0096.
@@index([userId, updatedAt, id])
// `measurements_legacy_step_consolidation_idx` is a partial index
// over `type = 'ACTIVITY_STEPS' AND deleted_at IS NULL` — see
// migration 0087. It accelerates the v1.5.6 legacy step-consolidation
@@ -837,6 +860,19 @@ enum MedicationDeliveryForm {
@@map("medication_delivery_form")
}
/// v1.7.0 — schedule-type discriminator. SCHEDULED is the default (rrule
/// / rolling / legacy cadence as today). PRN = as-needed: never
/// projected, never reminded, excluded from compliance expected-count,
/// still loggable. CYCLIC = N weeks on / M weeks off from the anchor;
/// within an on-week the rrule / legacy cadence applies as usual.
enum MedicationScheduleType {
SCHEDULED
PRN
CYCLIC
@@map("medication_schedule_type")
}
model Medication {
id String @id @default(cuid())
userId String @map("user_id")
@@ -881,6 +917,16 @@ model Medication {
/// A one-time injection is `oneShot = true` + `deliveryForm =
/// INJECTION`. The migration backfills ORAL for every existing row.
deliveryForm MedicationDeliveryForm @default(ORAL) @map("delivery_form")
/// v1.7.0 — iOS Live Activity opt-in for this medication's reminders.
/// Default false; existing rows backfill false. The server stores +
/// echoes the flag; the iOS client owns the ActivityKit lifecycle. No
/// server-side behaviour hangs off it.
liveActivityEnabled Boolean @default(false) @map("live_activity_enabled")
/// v1.7.0 — iOS 26 AlarmKit critical-reminder opt-in. Critical alarms
/// bypass the device mute switch / Focus. Default false; the server
/// stores the preference only — it does not itself authorise anything
/// server-side.
criticalAlarmEnabled Boolean @default(false) @map("critical_alarm_enabled")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -962,6 +1008,19 @@ model MedicationSchedule {
/// Zod layer; the column itself is unconstrained at the DB so
/// future extensions don't need a new migration.
rollingIntervalDays Int? @map("rolling_interval_days")
/// v1.7.0 — schedule-type discriminator. SCHEDULED = the existing rrule
/// / rolling / legacy cadence. PRN = as-needed (never projected,
/// reminded, or counted in compliance expected; still loggable).
/// CYCLIC = N weeks on / M weeks off, gating whichever inner cadence
/// the rrule / legacy fields describe. Backfills SCHEDULED.
scheduleType MedicationScheduleType @default(SCHEDULED) @map("schedule_type")
/// v1.7.0 — cyclic "on" weeks. Only meaningful when
/// `scheduleType = CYCLIC`. The phase repeats (cyclicOnWeeks on, then
/// cyclicOffWeeks off) from the medication's `startsOn ?? createdAt`.
cyclicOnWeeks Int? @map("cyclic_on_weeks")
/// v1.7.0 — cyclic "off" weeks. Only meaningful when
/// `scheduleType = CYCLIC`.
cyclicOffWeeks Int? @map("cyclic_off_weeks")
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
telegramMessages TelegramReminderMessage[]
@@ -1028,6 +1087,21 @@ model MedicationIntakeEvent {
/// is the only surface that writes this column). Existing rows stay
/// NULL forever and the read paths treat NULL as "no site recorded".
injectionSite InjectionSite? @map("injection_site")
// v1.7.0 sync — server-set mutation timestamp. The model had no
// `updatedAt`; the offline-sync keyset feed orders on it, so it is
// added here. `@updatedAt` makes Prisma maintain it on every write.
updatedAt DateTime @updatedAt @map("updated_at")
// v1.7.0 sync — reconciliation counter. An intake is an immutable
// fact, so a "correction" is a tombstone + re-insert rather than an
// in-place edit; syncVersion still bumps on the soft-delete write so
// the feed echoes a monotonic value. Default 0 for legacy rows.
syncVersion Int @default(0) @map("sync_version")
// v1.7.0 sync — soft-delete tombstone. The user-facing per-event
// DELETE route flips this (and bumps syncVersion) rather than removing
// the row, so `/api/sync/changes` surfaces the deletion as a tombstone
// keyed on the server `id`. NULL = live; non-NULL = tombstoned.
// Read paths (today / compliance / lists) filter `deletedAt: null`.
deletedAt DateTime? @map("deleted_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
@@ -1042,6 +1116,9 @@ model MedicationIntakeEvent {
/// inserts (WEB / TELEGRAM / IMPORT) on the same slot.
@@unique([userId, medicationId, scheduledFor, source])
@@index([userId, medicationId, scheduledFor])
// v1.7.0 sync — keyset support for the `/api/sync/changes` intake feed
// (`where userId` ordered by `(updatedAt asc, id asc)`).
@@index([userId, updatedAt, id])
@@map("medication_intake_events")
}
@@ -1273,11 +1350,16 @@ model TelegramReminderMessage {
phase ReminderPhase
sentAt DateTime @default(now()) @map("sent_at")
date String // YYYY-MM-DD for day grouping
/// v1.7.0 — time-of-day this reminder belongs to (HH:mm, user-local).
/// Empty string = the single legacy window (pre-v1.7 rows + single-time
/// schedules). Part of the dedup key so a multi-time schedule dispatches
/// once per time-of-day per phase per day instead of once per day.
timeOfDay String @default("") @map("time_of_day")
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
schedule MedicationSchedule @relation(fields: [scheduleId], references: [id], onDelete: Cascade)
@@unique([medicationId, scheduleId, date, phase])
@@unique([medicationId, scheduleId, date, phase, timeOfDay], map: "telegram_reminder_messages_med_sched_date_phase_tod_key")
@@index([medicationId, date])
@@map("telegram_reminder_messages")
}
@@ -1441,11 +1523,28 @@ model MoodEntry {
syncedAt DateTime @default(now()) @map("synced_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// v1.7.0 sync — LWW reconciliation counter for the offline-sync feed.
// Monotonically bumped on every server-side mutation (update +
// soft-delete). Mood is last-writer-wins by syncVersion: the higher
// value wins. Default 0 for the additive migration so legacy rows
// round-trip unchanged.
syncVersion Int @default(0) @map("sync_version")
// v1.7.0 sync — soft-delete tombstone. The user-facing DELETE route
// flips this (and bumps syncVersion) rather than removing the row, so
// the `/api/sync/changes` feed surfaces the deletion as a tombstone
// keyed on the server `id` to paired clients offline at delete time.
// NULL = live row; non-NULL = tombstoned at the recorded instant.
// Every list / detail / analytics / rollup read filters
// `deletedAt: null` so tombstoned rows never surface in normal reads.
deletedAt DateTime? @map("deleted_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, date, moodLoggedAt])
@@index([userId, date])
// v1.7.0 sync — keyset support for the `/api/sync/changes` mood feed
// (`where userId` ordered by `(updatedAt asc, id asc)`).
@@index([userId, updatedAt, id])
@@map("mood_entries")
}
@@ -1938,6 +2037,13 @@ model Device {
model String?
apnsToken String? @map("apns_token")
apnsEnvironment String? @map("apns_environment") // "sandbox" | "production"
/// v1.7.0 — per-device medication-delivery override. NULL = inherit the
/// user-level roaming default
/// (`User.notificationPrefs.medication.deliveryDefault`). "server"
/// forces server APNs for this device; "client" forces local. Stored +
/// echoed in v1.7.0; cron suppression stays user-level (APNs fans out to
/// all the user's devices and iOS dedupes locally).
medicationDelivery String? @map("medication_delivery")
lastSeen DateTime @default(now()) @map("last_seen")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -18,7 +18,13 @@ import { join } from "node:path";
* strip paints from the slim slice as soon as it lands; thick fields
* stream in afterwards. Without this dual-mount the cold-mount UX
* waterfall regresses to the v1.4.39.1 behaviour, so the test pins the
* exact two call shapes.
* two call shapes.
*
* v1.7.0 W6 — both calls now carry an `enabled` gate so the unified
* snapshot rollout flag can disable them in favour of a single
* `/api/dashboard/snapshot` cell. With the flag OFF (default) the
* slim/thick split still drives the page, so the protective intent
* holds; the matchers only relaxed to tolerate the `enabled` arg.
*/
const ROOT = join(__dirname, "../../..");
const PAGE_PATH = join(ROOT, "src/app/page.tsx");
@@ -31,10 +37,11 @@ describe("v1.4.39.2 — dashboard analytics slim/thick split", () => {
it("mounts both the slim slice and the thick slice in parallel", () => {
const src = load(PAGE_PATH);
// Slim slice — `?slice=summaries` paints the per-type tile strip.
expect(src).toMatch(/useAnalyticsQuery\(\{\s*slice:\s*"summaries"\s*\}\)/);
// Thick slice — the bare `useAnalyticsQuery()` (no slice) feeds
// the BD-Zielbereich + glucose tiles. Both must coexist.
expect(src).toMatch(/useAnalyticsQuery\(\)/);
expect(src).toMatch(/useAnalyticsQuery\(\{\s*\n?\s*slice:\s*"summaries"/);
// Thick slice — the no-slice `useAnalyticsQuery` feeds the
// BD-Zielbereich + glucose tiles. Both must coexist. The thick
// call now opens with `{ enabled:` (no `slice` key).
expect(src).toMatch(/useAnalyticsQuery\(\{\s*\n?\s*enabled:/);
});
it("merges slim and thick results so call-sites stay shape-stable", () => {
@@ -2,28 +2,99 @@ import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { resolveDashboardFirstPaintGate } from "../page";
/**
* v1.4.43 W11-M5 — dashboard tile-strip skeleton during slow slim
* fetches.
* v1.4.43 W11-M5 — dashboard tile-strip skeleton during slow first
* paint.
*
* Pre-fix the strip only painted once `trendCards.length > 0`. When
* both slim + thick `/api/analytics` slices lagged (cache eviction,
* cold start) the user saw the page header + zero tiles + then a
* burst of tiles 9 s later. The fix paints a layout-stable silhouette
* keyed off the user's configured tile count
* (`layout.widgets.filter(w => w.tileVisible ?? w.visible).length`)
* while `analyticsSlimQuery.isLoading` is still in flight, so the
* page reserves the strip's footprint immediately and swaps to the
* real tiles the moment slim resolves.
* Pre-fix the strip only painted once `trendCards.length > 0`. When the
* driving query lagged (cache eviction, cold start) the user saw the
* page header + zero tiles + then a burst of tiles seconds later. The
* fix paints a layout-stable silhouette keyed off the user's configured
* tile count (`layout.widgets.filter(w => w.tileVisible ?? w.visible).length`)
* while the primary query is still in flight, so the page reserves the
* strip's footprint immediately and swaps to the real tiles the moment
* the query resolves.
*
* Textual pins keep the contract honest — render-mounting the
* dashboard page would haul in TanStack-Query, auth, i18n, layout
* and analytics scaffolding for a guard a substring scan already
* proves.
* v1.7.0 — the gate now keys off `primaryLoading`, which resolves to
* the snapshot cell under `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=true` and the
* slim analytics cell otherwise. A disabled TanStack query reports
* `isLoading: false`, so keying off the slim query in snapshot mode
* (where it is `enabled: false`) flashed the empty state for the whole
* snapshot fetch. The pure `resolveDashboardFirstPaintGate` carries the
* behaviour; textual pins keep the wiring honest.
*/
const PAGE_PATH = join(process.cwd(), "src/app/page.tsx");
describe("v1.4.43 W11 — dashboard tile-strip skeleton", () => {
describe("resolveDashboardFirstPaintGate", () => {
const base = {
trendCardCount: 0,
chartCount: 0,
configuredTileCount: 3,
primaryLoading: false,
};
it("shows the skeleton (not the empty state) while the primary query loads", () => {
const gate = resolveDashboardFirstPaintGate({
...base,
primaryLoading: true,
});
expect(gate.showTileStripSkeleton).toBe(true);
expect(gate.showEmptyState).toBe(false);
});
it("snapshot mode: a loading snapshot query suppresses the empty-state flash", () => {
// Snapshot mode passes snapshotQuery.isLoading as primaryLoading.
// While in flight there are zero trend cards / charts, but the
// empty state must NOT fire — the skeleton carries the footprint.
const gate = resolveDashboardFirstPaintGate({
trendCardCount: 0,
chartCount: 0,
configuredTileCount: 7,
primaryLoading: true,
});
expect(gate.showEmptyState).toBe(false);
expect(gate.showTileStripSkeleton).toBe(true);
});
it("shows the empty state only once loading settles with no data", () => {
const gate = resolveDashboardFirstPaintGate(base);
expect(gate.showTileStripSkeleton).toBe(false);
expect(gate.showEmptyState).toBe(true);
});
it("does not show the empty state when tiles resolved", () => {
const gate = resolveDashboardFirstPaintGate({
...base,
trendCardCount: 4,
});
expect(gate.showEmptyState).toBe(false);
expect(gate.showTileStripSkeleton).toBe(false);
});
it("does not skeleton when no tiles are configured", () => {
const gate = resolveDashboardFirstPaintGate({
...base,
configuredTileCount: 0,
primaryLoading: true,
});
expect(gate.showTileStripSkeleton).toBe(false);
// No configured tiles and no charts while loading: still no empty
// state until loading settles (avoids the flash).
expect(gate.showEmptyState).toBe(false);
});
it("does not show the empty state when a chart has data", () => {
const gate = resolveDashboardFirstPaintGate({
...base,
chartCount: 1,
});
expect(gate.showEmptyState).toBe(false);
});
});
describe("v1.4.43 W11 — dashboard tile-strip skeleton (wiring)", () => {
it("derives `configuredTileCount` from the resolved layout", () => {
const src = readFileSync(PAGE_PATH, "utf8");
expect(src).toMatch(
@@ -31,10 +102,17 @@ describe("v1.4.43 W11 — dashboard tile-strip skeleton", () => {
);
});
it("gates the skeleton on slim-analytics loading + empty trend cards + configured tiles", () => {
it("derives `primaryLoading` from the snapshot vs slim query", () => {
const src = readFileSync(PAGE_PATH, "utf8");
expect(src).toMatch(
/const\s+showTileStripSkeleton\s*=\s*[\s\S]*?trendCards\.length\s*===\s*0[\s\S]*?analyticsSlimQuery\.isLoading[\s\S]*?configuredTileCount\s*>\s*0/,
/const\s+primaryLoading\s*=\s*snapshotEnabled\s*\?\s*snapshotQuery\.isLoading\s*:\s*analyticsSlimQuery\.isLoading;/,
);
});
it("routes the gate through `resolveDashboardFirstPaintGate`", () => {
const src = readFileSync(PAGE_PATH, "utf8");
expect(src).toMatch(
/resolveDashboardFirstPaintGate\(\{[\s\S]*?primaryLoading,[\s\S]*?\}\)/,
);
});
@@ -63,10 +141,8 @@ describe("v1.4.43 W11 — dashboard tile-strip skeleton", () => {
expect(block).toContain("motion-reduce:animate-none");
});
it("suppresses the EmptyState while the skeleton is showing", () => {
it("gates the EmptyState on the resolved `showEmptyState` flag", () => {
const src = readFileSync(PAGE_PATH, "utf8");
expect(src).toMatch(
/trendCards\.length\s*===\s*0\s*&&\s*charts\.length\s*===\s*0\s*&&\s*!showTileStripSkeleton/,
);
expect(src).toMatch(/if\s*\(showEmptyState\)\s*\{/);
});
});
+3
View File
@@ -78,6 +78,9 @@ export const PUT = apiHandler(async (req: Request) => {
verbosity: parsed.data.verbosity,
excludeCount: parsed.data.excludeMetrics.length,
showEvidence: parsed.data.showEvidenceByDefault,
// v1.7.0 — `null` when the caller left `dataClusters` absent
// (legacy defaults stay in force); otherwise the selected count.
clusterCount: parsed.data.dataClusters?.length ?? null,
},
});
return apiSuccess(parsed.data);
+60 -1
View File
@@ -23,16 +23,75 @@
* leave the device row alive with all its tokens revoked.
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { deviceDeliverySchema } from "@/lib/validations/notification-prefs";
import { revokeDeviceCascade } from "@/lib/devices/revoke";
interface RouteContext {
params: Promise<{ id: string }>;
}
/**
* v1.7.0 — per-device medication-delivery override. NULL clears the
* override (the device inherits the user-level roaming default).
*/
const devicePatchSchema = z.object({
medicationDelivery: deviceDeliverySchema,
});
export const PATCH = apiHandler(
async (request: NextRequest, context: RouteContext) => {
const { user } = await requireAuth();
const { id } = await context.params;
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const parsed = devicePatchSchema.safeParse(body);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422);
}
// Ownership boundary — scope the update by userId so a cross-user id
// can't be patched, and 404 rather than leak existence.
const result = await prisma.device.updateMany({
where: { id, userId: user.id },
data: { medicationDelivery: parsed.data.medicationDelivery },
});
if (result.count === 0) {
return apiError("Device not found", 404);
}
const device = await prisma.device.findUnique({
where: { id },
select: { id: true, medicationDelivery: true },
});
annotate({
action: {
name: "auth.me.devices.update",
entity_type: "device",
entity_id: id,
},
meta: { medication_delivery: parsed.data.medicationDelivery ?? "inherit" },
});
return apiSuccess({
id,
medicationDelivery: device?.medicationDelivery ?? null,
});
},
);
export const DELETE = apiHandler(
async (_request: NextRequest, context: RouteContext) => {
const { user } = await requireAuth();
@@ -87,7 +87,10 @@ describe("GET /api/auth/me/notification-prefs", () => {
const env = (await res.json()) as {
data: { medication: { clientManaged: boolean } };
};
expect(env.data).toEqual({ medication: { clientManaged: false } });
expect(env.data).toEqual({
medication: { clientManaged: false, deliveryDefault: "server" },
mood: { reminderHour: 22 },
});
});
it("returns the resolved prefs when the row holds a value", async () => {
@@ -103,7 +106,10 @@ describe("GET /api/auth/me/notification-prefs", () => {
const env = (await res.json()) as {
data: { medication: { clientManaged: boolean } };
};
expect(env.data).toEqual({ medication: { clientManaged: true } });
expect(env.data).toEqual({
medication: { clientManaged: true, deliveryDefault: "server" },
mood: { reminderHour: 22 },
});
});
it("returns defaults when the persisted shape has drifted", async () => {
@@ -121,7 +127,10 @@ describe("GET /api/auth/me/notification-prefs", () => {
const env = (await res.json()) as {
data: { medication: { clientManaged: boolean } };
};
expect(env.data).toEqual({ medication: { clientManaged: false } });
expect(env.data).toEqual({
medication: { clientManaged: false, deliveryDefault: "server" },
mood: { reminderHour: 22 },
});
});
});
@@ -150,12 +159,18 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
const env = (await res.json()) as {
data: { medication: { clientManaged: boolean } };
};
expect(env.data).toEqual({ medication: { clientManaged: true } });
expect(env.data).toEqual({
medication: { clientManaged: true, deliveryDefault: "server" },
mood: { reminderHour: 22 },
});
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: {
notificationPrefs: { medication: { clientManaged: true } },
notificationPrefs: {
medication: { clientManaged: true, deliveryDefault: "server" },
mood: { reminderHour: 22 },
},
},
});
@@ -164,8 +179,14 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
expect.objectContaining({
userId: "user-1",
details: expect.objectContaining({
previous: { medication: { clientManaged: false } },
next: { medication: { clientManaged: true } },
previous: {
medication: { clientManaged: false, deliveryDefault: "server" },
mood: { reminderHour: 22 },
},
next: {
medication: { clientManaged: true, deliveryDefault: "server" },
mood: { reminderHour: 22 },
},
changed: ["medication"],
}),
}),
@@ -222,7 +243,10 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: {
notificationPrefs: { medication: { clientManaged: true } },
notificationPrefs: {
medication: { clientManaged: true, deliveryDefault: "server" },
mood: { reminderHour: 22 },
},
},
});
});
@@ -243,16 +267,59 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
const env = (await res.json()) as {
data: { medication: { clientManaged: boolean } };
};
expect(env.data).toEqual({ medication: { clientManaged: true } });
expect(env.data).toEqual({
medication: { clientManaged: true, deliveryDefault: "server" },
mood: { reminderHour: 22 },
});
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: {
notificationPrefs: { medication: { clientManaged: true } },
notificationPrefs: {
medication: { clientManaged: true, deliveryDefault: "server" },
mood: { reminderHour: 22 },
},
},
});
});
it("v1.7.0 — persists a custom mood.reminderHour", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ mood: { reminderHour: 9 } }),
);
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { mood: { reminderHour: number } };
};
expect(env.data.mood).toEqual({ reminderHour: 9 });
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: {
notificationPrefs: {
medication: { clientManaged: false, deliveryDefault: "server" },
mood: { reminderHour: 9 },
},
},
});
});
it("v1.7.0 — rejects a mood.reminderHour outside 0..23 with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ mood: { reminderHour: 24 } }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("returns 429 when the per-user rate-limit fires", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
@@ -2,7 +2,8 @@
* v1.4.49 M-DOUBLE-REMINDER — per-user notification preferences endpoint.
*
* GET /api/auth/me/notification-prefs — current resolved prefs.
* PATCH /api/auth/me/notification-prefs — body `{ medication?: { clientManaged?: boolean } }`.
* PATCH /api/auth/me/notification-prefs — body `{ medication?: { clientManaged?: boolean },
* mood?: { reminderHour?: 0..23 } }`.
* Deep-merges the supplied
* shape over the persisted
* row so future categories
+22
View File
@@ -3,6 +3,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { setOnboardingPendingCookie } from "@/lib/auth/session";
import { buildAvatarUrl } from "@/lib/avatar";
import { decrypt } from "@/lib/crypto";
export const dynamic = "force-dynamic";
@@ -18,6 +19,19 @@ export const GET = apiHandler(async () => {
annotate({ action: { name: "auth.me" } });
// v1.7.0 — patient-identity fields for the health-record export. The
// KVNR is stored encrypted; decrypt fail-soft so a key-rotation gap on
// one row never 500s the whole profile fetch (the field just reads
// null and the user re-enters it).
let insuranceNumber: string | null = null;
if (user.insuranceNumberEncrypted) {
try {
insuranceNumber = decrypt(user.insuranceNumberEncrypted);
} catch {
insuranceNumber = null;
}
}
return apiSuccess({
id: user.id,
username: user.username,
@@ -38,6 +52,10 @@ export const GET = apiHandler(async () => {
? buildAvatarUrl(user.id, user.avatarUpdatedAt)
: null,
glucoseUnit: user.glucoseUnit ?? null,
// v1.7.0 — global metric/imperial display preference. Canonical
// storage stays SI; this only drives the display-time transform
// branch. Null defaults to "metric" on the client.
unitPreference: user.unitPreference === "imperial" ? "imperial" : "metric",
lastReportPracticeName: user.lastReportPracticeName ?? null,
// v1.4.47 W3 — per-user Coach opt-out. Default `false` if the
// column is absent (partial-deploy rollback safety, see migration
@@ -45,5 +63,9 @@ export const GET = apiHandler(async () => {
// `user.disableCoach` BELOW the operator-level `flags.coach`
// short-circuit; both gates must agree to paint the affordance.
disableCoach: user.disableCoach ?? false,
// v1.7.0 — health-record export identity fields. All optional.
fullName: user.fullName ?? null,
insurerName: user.insurerName ?? null,
insuranceNumber,
});
});
@@ -0,0 +1,159 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 59,
resetAt: Date.now() + 60_000,
}),
rateLimitHeaders: () => ({}),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, PATCH } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
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 },
};
function mkPatch(body: unknown): Request {
return new Request("http://localhost/api/auth/me/unit-preference", {
method: "PATCH",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("GET /api/auth/me/unit-preference", () => {
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/unit-preference"),
);
expect(res.status).toBe(401);
});
it("defaults to metric for a fresh user (null column)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
unitPreference: null,
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/unit-preference"),
);
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { unitPreference: string };
};
expect(env.data.unitPreference).toBe("metric");
});
it("defaults to metric when the column is missing (partial rollback)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({} as never);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/unit-preference"),
);
const env = (await res.json()) as { data: { unitPreference: string } };
expect(env.data.unitPreference).toBe("metric");
});
it("returns imperial when set", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
unitPreference: "imperial",
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/unit-preference"),
);
const env = (await res.json()) as { data: { unitPreference: string } };
expect(env.data.unitPreference).toBe("imperial");
});
});
describe("PATCH /api/auth/me/unit-preference", () => {
it("sets imperial and writes the audit row", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
unitPreference: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ unitPreference: "imperial" }),
);
expect(res.status).toBe(200);
const env = (await res.json()) as { data: { unitPreference: string } };
expect(env.data.unitPreference).toBe("imperial");
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { unitPreference: "imperial" },
});
expect(auditLog).toHaveBeenCalledWith(
"user.unit-preference.update",
expect.objectContaining({
userId: "user-1",
details: expect.objectContaining({ previous: "metric", next: "imperial" }),
}),
);
});
it("rejects an unknown preference value with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ unitPreference: "furlongs" }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("rejects malformed JSON with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const req = new Request("http://localhost/api/auth/me/unit-preference", {
method: "PATCH",
body: "{ not valid json",
headers: { "Content-Type": "application/json" },
});
const res = await (PATCH as (r: Request) => Promise<Response>)(req);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,124 @@
/**
* v1.7.0 — per-user metric/imperial display preference endpoint.
*
* GET /api/auth/me/unit-preference — current preference.
* PATCH /api/auth/me/unit-preference — body
* `{ unitPreference: "metric" | "imperial" }`.
*
* Canonical storage stays SI on every measurement row; this only
* selects which branch of the display-time transform registry the
* client renders (km/h vs mph, km vs mi). Default "metric". The toggle
* is reachable from Settings as a segmented control. Idempotent — the
* endpoint always returns the resolved next state so the client can
* hard-set the optimistic update without an extra round-trip.
*
* Mirrors the `disable-coach` per-user-scalar pattern: 60/min rate
* limit, Zod safeParse → 422 via `returnAllZodIssues`, audit-log row,
* field-by-field write (no mass assignment).
*/
import { z } from "zod";
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { DEFAULT_UNIT_PREFERENCE } from "@/lib/measurements/display-transform";
const patchBodySchema = z.object({
unitPreference: z.enum(["metric", "imperial"]),
});
export const dynamic = "force-dynamic";
const PATCH_RATE_LIMIT = 60;
const PATCH_WINDOW_MS = 60_000;
type UnitPreferenceResponse = {
unitPreference: "metric" | "imperial";
};
function resolve(value: string | null | undefined): "metric" | "imperial" {
return value === "imperial" ? "imperial" : DEFAULT_UNIT_PREFERENCE;
}
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "auth.me.unit-preference.get" } });
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { unitPreference: true },
});
const payload: UnitPreferenceResponse = {
unitPreference: resolve(row?.unitPreference),
};
return apiSuccess(payload);
});
export const PATCH = apiHandler(async (req: Request) => {
const { user } = await requireAuth();
const rl = await checkRateLimit(
`unit-preference:patch:${user.id}`,
PATCH_RATE_LIMIT,
PATCH_WINDOW_MS,
);
if (!rl.allowed) {
const response = apiError("Too many requests", 429);
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
response.headers.set(k, v);
}
return response;
}
let body: unknown;
try {
body = await req.json();
} catch {
throw new HttpError(422, "unit-preference.body.invalid_json");
}
const parsed = patchBodySchema.safeParse(body);
if (!parsed.success) {
annotate({
action: { name: "auth.me.unit-preference.patch.invalid_shape" },
});
return returnAllZodIssues(parsed.error, 422);
}
const next = parsed.data.unitPreference;
const previous = await prisma.user.findUnique({
where: { id: user.id },
select: { unitPreference: true },
});
await prisma.user.update({
where: { id: user.id },
data: { unitPreference: next },
});
await auditLog("user.unit-preference.update", {
userId: user.id,
ipAddress: getClientIp(req),
details: {
previous: resolve(previous?.unitPreference),
next,
},
});
annotate({
action: { name: "auth.me.unit-preference.patch" },
meta: { unitPreference: next },
});
const payload: UnitPreferenceResponse = { unitPreference: next };
return apiSuccess(payload);
});
+3
View File
@@ -31,5 +31,8 @@ export const PUT = apiHandler(async (request: NextRequest) => {
dateOfBirth: result.user.dateOfBirth,
gender: result.user.gender,
timezone: result.user.timezone,
fullName: result.user.fullName,
insurerName: result.user.insurerName,
hasInsuranceNumber: result.user.hasInsuranceNumber,
});
});
@@ -0,0 +1,111 @@
/**
* v1.7.0 — stable machine `errorCode` on POST /api/auth/refresh.
*
* Native clients string-adjacent-detected refresh failure before this
* change. A stable `errorCode` lets iOS branch terminal re-auth
* (`auth.refresh.reuse` / `auth.refresh.revoked`) from a transient blip
* without parsing the human-prose `error`. This pins the mapping from
* `RotationFailureReason` to the wire `errorCode`.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/rate-limit", () => ({
checkAuthSurfaceRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 60,
resetAt: 0,
ip: "203.0.113.7",
}),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/native-client", () => ({
resolveTokenPolicy: vi.fn(() => ({
policy: "native",
accessTokenDays: 1,
refreshTokenDays: 60,
tokenLabel: "native",
})),
}));
vi.mock("@/lib/auth/refresh-token", () => ({
rotateRefreshToken: vi.fn(),
revokeRefreshToken: vi.fn(),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../route";
import { rotateRefreshToken } from "@/lib/auth/refresh-token";
function makeRequest(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/auth/refresh", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
type Envelope = { error: string; meta?: { errorCode?: string } };
beforeEach(() => {
vi.mocked(rotateRefreshToken).mockReset();
});
describe("POST /api/auth/refresh — stable errorCode (v1.7.0)", () => {
it("returns auth.refresh.reuse on a consumed-token replay", async () => {
vi.mocked(rotateRefreshToken).mockResolvedValue({
ok: false,
reason: "already_used",
});
const res = await POST(makeRequest({ refreshToken: "hlr_replayed" }));
expect(res.status).toBe(401);
const json = (await res.json()) as Envelope;
expect(json.meta?.errorCode).toBe("auth.refresh.reuse");
});
it("returns auth.refresh.revoked when the family was revoked out-of-band", async () => {
vi.mocked(rotateRefreshToken).mockResolvedValue({
ok: false,
reason: "revoked",
});
const res = await POST(makeRequest({ refreshToken: "hlr_revoked" }));
expect(res.status).toBe(401);
const json = (await res.json()) as Envelope;
expect(json.meta?.errorCode).toBe("auth.refresh.revoked");
});
it("returns auth.refresh.invalid for a not-found token", async () => {
vi.mocked(rotateRefreshToken).mockResolvedValue({
ok: false,
reason: "not_found",
});
const res = await POST(makeRequest({ refreshToken: "hlr_unknown" }));
expect(res.status).toBe(401);
const json = (await res.json()) as Envelope;
expect(json.meta?.errorCode).toBe("auth.refresh.invalid");
});
it("returns auth.refresh.invalid for an expired token", async () => {
vi.mocked(rotateRefreshToken).mockResolvedValue({
ok: false,
reason: "expired",
});
const res = await POST(makeRequest({ refreshToken: "hlr_expired" }));
expect(res.status).toBe(401);
const json = (await res.json()) as Envelope;
expect(json.meta?.errorCode).toBe("auth.refresh.invalid");
});
});
+27 -3
View File
@@ -12,7 +12,10 @@ import {
import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
import { resolveTokenPolicy } from "@/lib/auth/native-client";
import {
resolveTokenPolicy,
NATIVE_REFRESH_TOKEN_DAYS,
} from "@/lib/auth/native-client";
import {
rotateRefreshToken,
revokeRefreshToken,
@@ -58,7 +61,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
// Refresh always uses the native policy: a refresh token only exists for
// native callers, and we never want to upgrade it back to a 90d token.
if (policy.refreshTokenDays === null) {
policy.refreshTokenDays = 60;
policy.refreshTokenDays = NATIVE_REFRESH_TOKEN_DAYS;
policy.accessTokenDays = 1;
}
@@ -79,13 +82,34 @@ export const POST = apiHandler(async (request: NextRequest) => {
action: { name: "auth.token.refresh.failed" },
meta: { refresh_failure: result.reason },
});
// v1.7.0 — stable machine `errorCode` so native clients can branch
// re-auth-required from a transient blip without string-matching the
// human-prose `error`:
// - `auth.refresh.reuse` — a consumed token was presented again;
// the device's refresh family is now revoked (stolen-token
// signal). The client must re-pair, not retry.
// - `auth.refresh.revoked` — the family was revoked out-of-band
// (explicit logout, or reuse detected on another device). Also
// terminal: re-auth required.
// - `auth.refresh.invalid` — not found / expired. The client drops
// the token and re-authenticates.
if (result.reason === "already_used") {
return apiError(
"Refresh token reuse detected — please log in again.",
401,
{ errorCode: "auth.refresh.reuse" },
);
}
return apiError("Invalid refresh token", 401);
if (result.reason === "revoked") {
return apiError(
"Refresh token has been revoked — please log in again.",
401,
{ errorCode: "auth.refresh.revoked" },
);
}
return apiError("Invalid refresh token", 401, {
errorCode: "auth.refresh.invalid",
});
}
await auditLog("auth.token.refresh", {
@@ -0,0 +1,159 @@
/**
* v1.7.0 W6 — route tests for GET /api/dashboard/snapshot.
*
* - 401 envelope when unauthenticated;
* - 200 `{ data, error }` envelope with the snapshot body when
* authenticated;
* - a repeat call hits the cache (builder runs once);
* - the snapshot key carries a per-key TTL longer than the 120 s
* client refetch interval so a scheduled poll stays a cache hit
* (the entry outlives the analytics bucket's 60 s default), while a
* measurement-write invalidation still evicts it.
*
* The builder is mocked — its own assembly contract is covered by
* `src/lib/dashboard/__tests__/snapshot.test.ts`.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {},
}));
vi.mock("@/lib/auth/session", () => ({
getSession: vi.fn(),
}));
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: () => {},
})),
}));
const buildDashboardSnapshot = vi.fn();
vi.mock("@/lib/dashboard/snapshot", () => ({
buildDashboardSnapshot: (...a: unknown[]) => buildDashboardSnapshot(...a),
}));
import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { __resetAllCachesForTests, caches } from "@/lib/cache/server-cache";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "user-1",
username: "tester",
role: "USER" as const,
displayName: null,
timezone: "Europe/Berlin",
heightCm: 180,
dateOfBirth: null,
gender: "MALE",
glucoseUnit: "mg/dL",
onboardingTourCompleted: true,
disableCoach: false,
insightsCachedText: null,
insightsCachedAt: null,
dashboardWidgetsJson: null,
},
};
const SNAPSHOT_BODY = {
user: {
username: "tester",
timezone: "Europe/Berlin",
heightCm: 180,
dateOfBirth: null,
gender: "MALE",
glucoseUnit: "mg/dL",
onboardingTourCompleted: true,
greetingHour: 9,
},
layout: { version: 1, widgets: [] },
tiles: {
summaries: {},
lastSeenByType: {},
mood: { summary: null, entries: [] },
},
extras: null,
briefing: null,
briefingState: "preparing",
briefingUpdatedAt: null,
generatedAt: new Date().toISOString(),
};
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
function makeReq(): NextRequest {
return new NextRequest("http://localhost/api/dashboard/snapshot");
}
beforeEach(() => {
vi.clearAllMocks();
__resetAllCachesForTests();
buildDashboardSnapshot.mockResolvedValue(SNAPSHOT_BODY);
});
describe("GET /api/dashboard/snapshot", () => {
it("401s when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await callGet(makeReq());
expect(res.status).toBe(401);
const json = await res.json();
expect(json.data).toBeNull();
expect(typeof json.error).toBe("string");
});
it("returns the snapshot envelope when authenticated", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await callGet(makeReq());
expect(res.status).toBe(200);
const json = await res.json();
expect(json.error).toBeNull();
expect(json.data.briefingState).toBe("preparing");
expect(json.data.tiles).toBeDefined();
expect(json.data.extras).toBeNull();
expect(json.data.user.greetingHour).toBe(9);
// bfcache-friendly directive present.
expect(res.headers.get("Cache-Control")).toContain("private");
});
it("serves the second call from cache (builder runs once)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
await callGet(makeReq());
await callGet(makeReq());
expect(buildDashboardSnapshot).toHaveBeenCalledTimes(1);
});
it("stays cached past the 120 s client refetch interval (per-key TTL)", async () => {
vi.useFakeTimers();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
await callGet(makeReq());
// The analytics bucket default is 60 s; the snapshot key carries a
// longer per-key TTL so the 120 s scheduled refetch is a hit.
vi.advanceTimersByTime(120_000);
await callGet(makeReq());
expect(buildDashboardSnapshot).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});
it("evicts the snapshot on a measurement-write invalidation", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
await callGet(makeReq());
expect(buildDashboardSnapshot).toHaveBeenCalledTimes(1);
// A measurement write sweeps the `${userId}|` prefix, which covers
// the longer-TTL snapshot key.
invalidateUserMeasurements("user-1");
expect(caches.analytics.get("user-1|dashboard-snapshot")).toBeNull();
await callGet(makeReq());
expect(buildDashboardSnapshot).toHaveBeenCalledTimes(2);
});
});
+107
View File
@@ -0,0 +1,107 @@
/**
* GET /api/dashboard/snapshot
*
* v1.7.0 W6 — unified above-the-fold first-paint payload for the web
* dashboard. One `apiHandler`-wrapped GET that assembles every tile
* field in a single round-trip via `buildDashboardSnapshot` so the
* whole strip shares one completion moment instead of the legacy
* four-cell waterfall (slim analytics + thick analytics + mood + widget
* layout, each gated behind `/api/auth/me`).
*
* Cookie OR Bearer auth via `requireAuth()`; the dashboard is a
* cookie-session surface but the route does not gate on it. `userId`
* is narrowed from the resolved session — never a body field.
*
* The body is read-through `caches.analytics` keyed
* `${userId}|dashboard-snapshot`, but with a per-key TTL of
* `SNAPSHOT_CACHE_TTL_MS` (>= the client's 120 s refetch interval) so a
* scheduled refetch lands on a warm entry instead of re-running the
* full builder on every tick. It still rides the analytics bucket so
* the `${userId}|` eviction sweep on a measurement / mood / medication
* write covers it (see `src/lib/cache/invalidate.ts`) — correctness on
* writes is unchanged; only the idle-poll TTL is longer. Per-sub-query
* timings surface under `meta.snapshot.sub_*_ms` on the cache-miss path
* so a regression is attributable without re-instrumenting.
*
* No LLM is reachable from the builder — the daily briefing is lifted
* read-only from `User.insightsCachedText`. The nightly
* `insight-pregenerate` cron keeps that cache warm.
*/
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import { DASHBOARD_REFETCH_INTERVAL_MS } from "@/lib/queries/refetch-interval";
import {
buildDashboardSnapshot,
type DashboardSnapshot,
type SnapshotUserInput,
} from "@/lib/dashboard/snapshot";
export const dynamic = "force-dynamic";
/**
* Per-key TTL for the snapshot cache entry. Strictly greater than the
* client's 120 s refetch interval so a scheduled poll lands on a warm
* entry instead of a guaranteed miss that re-runs the full builder. The
* 60 s headroom absorbs interval jitter / a poll firing a touch late.
* The analytics bucket's 60 s default still governs the slim / thick /
* mood cells; only this key is lengthened. Eviction on writes is
* unchanged (the `${userId}|` prefix sweep / point-delete both ignore
* the TTL).
*/
const SNAPSHOT_CACHE_TTL_MS = DASHBOARD_REFETCH_INTERVAL_MS + 60_000;
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "dashboard.snapshot" } });
const timings: Record<string, number> = {};
const time = async <T>(
label: string,
builder: () => Promise<T>,
): Promise<T> => {
const t0 = Date.now();
const result = await builder();
timings[`snapshot.sub_${label}_ms`] = Date.now() - t0;
return result;
};
// `requireAuth().user` is the full Prisma `User` row, so every field
// the builder needs is already present — no extra round-trip.
const snapshotUser: SnapshotUserInput = {
id: user.id,
username: user.username,
displayName: user.displayName,
timezone: user.timezone,
heightCm: user.heightCm,
dateOfBirth: user.dateOfBirth,
gender: user.gender,
glucoseUnit: user.glucoseUnit,
onboardingTourCompleted: user.onboardingTourCompleted,
disableCoach: user.disableCoach,
insightsCachedText: user.insightsCachedText,
insightsCachedAt: user.insightsCachedAt,
dashboardWidgetsJson: user.dashboardWidgetsJson,
};
const body = await cached(
caches.analytics as ServerCache<DashboardSnapshot>,
`${user.id}|dashboard-snapshot`,
() => buildDashboardSnapshot(prisma, snapshotUser, { time }),
annotate,
SNAPSHOT_CACHE_TTL_MS,
);
// Only surface timings on the cache-miss path (the hit path skips the
// whole builder and leaves `timings` empty).
if (Object.keys(timings).length > 0) {
annotate({ meta: { ...timings, snapshot_extras_present: body.extras !== null } });
}
const response = apiSuccess(body);
response.headers.set("Cache-Control", NO_STORE_BUT_BFCACHE);
return response;
});
+4
View File
@@ -357,6 +357,8 @@ async function projectAndReadTodaysIntakes(
return prisma.medicationIntakeEvent.findMany({
where: {
userId,
// v1.7.0 sync — exclude tombstoned rows from the today tile.
deletedAt: null,
scheduledFor: { gte: todayStart, lt: todayEnd },
},
select: { id: true, takenAt: true, skipped: true },
@@ -478,6 +480,8 @@ async function buildDashboardSummary(
prisma.medicationIntakeEvent.findMany({
where: {
userId,
// v1.7.0 sync — exclude tombstoned rows from streak counting.
deletedAt: null,
scheduledFor: { gte: streakWindowStart },
OR: [{ takenAt: { not: null } }, { skipped: true }],
},
@@ -54,11 +54,18 @@ vi.mock("next/headers", () => ({
})),
}));
import { PUT, __resetAuditDedupMemoForTests } from "../route";
import { GET, PUT, __resetAuditDedupMemoForTests } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { annotate } from "@/lib/logging/context";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
import {
DASHBOARD_WIDGET_IDS,
DASHBOARD_IOS_ONLY_WIDGET_IDS,
DASHBOARD_WIDGET_CATALOGUE_IDS,
serializeDashboardLayout,
type DashboardLayout,
} from "@/lib/dashboard-layout";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -211,10 +218,14 @@ describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)",
});
it("caps received_shape_excerpt at 256 chars even for a large iOS payload", async () => {
// v1.7.0 #9 — unknown ids are now filtered out before Zod, so use a
// KNOWN id (survives the filter) with an out-of-range `order` (>99)
// and a long label to keep the payload large AND failing validation.
const widgets = Array.from({ length: 30 }, (_, i) => ({
id: `widget-${i}-${"x".repeat(20)}`,
id: DASHBOARD_WIDGET_IDS[0],
visible: true,
order: i,
order: 999 + i,
label: `${"x".repeat(20)}-${i}`,
}));
const res = await callPut(makeReq({ version: 99, widgets }));
expect(res.status).toBe(422);
@@ -303,3 +314,194 @@ describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)",
}
});
});
describe("PUT /api/dashboard/widgets — accept-and-ignore unknown ids (v1.7.0 #9)", () => {
const knownId = DASHBOARD_WIDGET_IDS[0];
it("persists known ids, drops unknown ids, returns 200, and annotates", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dashboardWidgetsJson: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await callPut(
makeReq({
version: 1,
widgets: [
{ id: knownId, visible: true, order: 0 },
{ id: "ios-only-future-tile", visible: true, order: 1 },
],
}),
);
expect(res.status).toBe(200);
// The persisted blob never carries the unknown id.
expect(prisma.user.update).toHaveBeenCalledTimes(1);
const updateArg = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as unknown as {
data: { dashboardWidgetsJson: { widgets: Array<{ id: string }> } };
};
const persistedIds = updateArg.data.dashboardWidgetsJson.widgets.map(
(w) => w.id,
);
expect(persistedIds).toContain(knownId);
expect(persistedIds).not.toContain("ios-only-future-tile");
// The drop is greppable via the annotation.
expect(annotate).toHaveBeenCalledWith(
expect.objectContaining({
action: { name: "dashboard.widgets.unknown-id-dropped" },
meta: expect.objectContaining({
dropped_ids: ["ios-only-future-tile"],
dropped_count: 1,
}),
}),
);
});
it("caps the logged dropped_ids array at 20 while keeping the full dropped_count (v1.7.0)", async () => {
// A large all-unknown payload — the unknown-id filter runs before
// Zod's `.max(20)`, so the wide-event line must not carry every id.
const widgets = Array.from({ length: 200 }, (_, i) => ({
id: `ios-unknown-${i}`,
visible: true,
order: i,
}));
const res = await callPut(makeReq({ version: 1, widgets }));
// All widgets unknown → surviving array is empty → 422 (min 1). The
// annotation fires regardless, before the Zod parse.
expect(res.status).toBe(422);
const dropAnnotate = vi.mocked(annotate).mock.calls.find(
(c) =>
(c[0] as { action?: { name?: string } }).action?.name ===
"dashboard.widgets.unknown-id-dropped",
);
expect(dropAnnotate, "unknown-id-dropped annotate call").toBeTruthy();
const meta = (dropAnnotate![0] as { meta?: Record<string, unknown> }).meta!;
expect(meta.dropped_count).toBe(200);
expect((meta.dropped_ids as string[]).length).toBe(20);
});
it("still 422s when a surviving entry is malformed (missing order)", async () => {
const res = await callPut(
makeReq({
version: 1,
widgets: [
{ id: knownId, visible: true }, // no `order`
{ id: "ios-only-future-tile", visible: true, order: 1 },
],
}),
);
expect(res.status).toBe(422);
});
});
const callGet = GET as unknown as () => Promise<Response>;
describe("dashboard widgets — 27-id catalogue round-trip (v1.7.0 W1)", () => {
it("PUT of a full 27-id layout persists every id (iOS-only round-trip)", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dashboardWidgetsJson: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const widgets = DASHBOARD_WIDGET_CATALOGUE_IDS.map((id, i) => ({
id,
visible: true,
tileVisible: true,
order: i,
}));
const res = await callPut(makeReq({ version: 1, widgets }));
expect(res.status).toBe(200);
// No id was dropped — the unknown-id annotation must NOT fire.
const dropAnnotate = vi.mocked(annotate).mock.calls.find(
(c) =>
(c[0] as { action?: { name?: string } }).action?.name ===
"dashboard.widgets.unknown-id-dropped",
);
expect(dropAnnotate).toBeUndefined();
expect(prisma.user.update).toHaveBeenCalledTimes(1);
const updateArg = vi.mocked(prisma.user.update).mock
.calls[0]?.[0] as unknown as {
data: { dashboardWidgetsJson: { widgets: Array<{ id: string }> } };
};
const persistedIds = updateArg.data.dashboardWidgetsJson.widgets.map(
(w) => w.id,
);
expect(persistedIds.sort()).toEqual([...DASHBOARD_WIDGET_CATALOGUE_IDS].sort());
// The response body echoes the full persisted layout.
const body = (await res.json()) as {
data: { widgets: Array<{ id: string }> };
};
expect(body.data.widgets.map((w) => w.id).sort()).toEqual(
[...DASHBOARD_WIDGET_CATALOGUE_IDS].sort(),
);
});
it("GET returns the full persisted layout including all 11 iOS-only ids", async () => {
const stored: DashboardLayout = serializeDashboardLayout({
version: 1,
widgets: DASHBOARD_WIDGET_CATALOGUE_IDS.map((id, i) => ({
id,
visible: true,
tileVisible: true,
order: i,
})),
});
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dashboardWidgetsJson: stored,
} as never);
const res = await callGet();
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { widgets: Array<{ id: string }> };
};
const ids = body.data.widgets.map((w) => w.id);
for (const iosId of DASHBOARD_IOS_ONLY_WIDGET_IDS) {
expect(ids).toContain(iosId);
}
expect(ids.sort()).toEqual([...DASHBOARD_WIDGET_CATALOGUE_IDS].sort());
});
it("an id genuinely outside the 27-catalogue still drops on PUT", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dashboardWidgetsJson: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await callPut(
makeReq({
version: 1,
widgets: [
{ id: "hrv", visible: true, tileVisible: true, order: 0 }, // iOS-only — survives
{ id: "glp1", visible: true, tileVisible: true, order: 1 }, // retired — drops
],
}),
);
expect(res.status).toBe(200);
const updateArg = vi.mocked(prisma.user.update).mock
.calls[0]?.[0] as unknown as {
data: { dashboardWidgetsJson: { widgets: Array<{ id: string }> } };
};
const persistedIds = updateArg.data.dashboardWidgetsJson.widgets.map(
(w) => w.id,
);
expect(persistedIds).toContain("hrv");
expect(persistedIds).not.toContain("glp1");
expect(annotate).toHaveBeenCalledWith(
expect.objectContaining({
action: { name: "dashboard.widgets.unknown-id-dropped" },
meta: expect.objectContaining({
dropped_ids: ["glp1"],
dropped_count: 1,
}),
}),
);
});
});
+62 -13
View File
@@ -19,7 +19,7 @@ import {
resolveDashboardLayout,
serializeDashboardLayout,
DEFAULT_DASHBOARD_LAYOUT,
DASHBOARD_WIDGET_IDS,
DASHBOARD_WIDGET_CATALOGUE_IDS,
COMPARISON_BASELINES,
CHART_OVERLAY_KEYS,
type ChartOverlayPrefsMap,
@@ -32,17 +32,22 @@ import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import { redactSensitiveFields } from "@/lib/observability/redact-payload";
import type { NextRequest } from "next/server";
// Single source of truth — every widget id rendered by the Settings →
// Dashboard UI (`src/components/settings/dashboard-layout-section.tsx`
// iterates the full layout from `DEFAULT_DASHBOARD_LAYOUT`). Missing
// one here makes the PUT 422 silently — the toast surfaces "Layout
// konnte nicht gespeichert werden" — and the user's tile-toggle looks
// like it does nothing because the save round-trip never completes.
// v1.4.16 A5 root-cause: `achievements` was absent from this enum so
// every save attempted with the achievements widget present (i.e.
// every save against the default layout) was rejected. We now derive
// the enum from `DASHBOARD_WIDGET_IDS` so the two lists cannot drift.
const widgetIdEnum = z.enum(DASHBOARD_WIDGET_IDS);
// Single source of truth — every widget id the layout accepts. The
// Settings → Dashboard UI iterates the full layout; missing an id here
// used to make the PUT 422 silently (the toast surfaced "Layout konnte
// nicht gespeichert werden") and the user's tile-toggle looked like it
// did nothing because the save round-trip never completed. v1.4.16 A5
// root-cause: `achievements` was absent so every save against the
// default layout was rejected.
//
// v1.7.0 W1 — widened from the 16 web-known ids to the full 27-id
// catalogue (16 web + 11 iOS-only). The native client materialises the
// 11 HK-completeness tiles in its own default layout and PUTs the union;
// accepting + persisting all 27 lets iOS drop its local merge
// workarounds (`byMergingIosOnlyDefaults` / `byRestoringIosOnlyWidgets`).
// The web surface still renders only its 16 tiles and retains the other
// 11 untouched in the stored blob.
const widgetIdEnum = z.enum(DASHBOARD_WIDGET_CATALOGUE_IDS);
// v1.4.43 B2 — dedup helper extracted to `src/lib/audit-dedup.ts` so both
// `/api/dashboard/widgets` and `/api/dashboard/chart-overlay-prefs` share
@@ -65,7 +70,10 @@ const layoutSchema = z.object({
}),
)
.min(1)
.max(20),
// v1.7.0 W1 — raised from 20 to 30 so the full 27-id catalogue PUT
// (web 16 + iOS-only 11) fits with headroom; the enum still bounds
// each id to one of the 27.
.max(30),
// v1.4.16 phase B8 — comparison baseline (Vormonat / Vorjahr) rides
// on the layout blob per research §7 Q3 (no Prisma migration). Optional
// so v1.4.15 clients that don't know the field can still PUT.
@@ -137,6 +145,47 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
// v1.7.0 W1 — accept-and-ignore widget ids OUTSIDE the 27-id catalogue
// on write. The 27 ids (16 web + 11 iOS-only) all validate + persist;
// only a genuinely-unknown id (a retired tile, or a typo) is filtered
// out here BEFORE Zod so it can't 422 the whole blob. The strict enum
// still validates the surviving ids, so a malformed entry (non-string
// id, missing `order`) still 422s. An id outside the catalogue
// silently vanishes — acceptable, and greppable via the annotation.
const knownWidgetIds = new Set<string>(DASHBOARD_WIDGET_CATALOGUE_IDS);
if (
body &&
typeof body === "object" &&
Array.isArray((body as { widgets?: unknown }).widgets)
) {
const widgetsBody = body as {
widgets: Array<{ id?: unknown }>;
};
const droppedIds = widgetsBody.widgets
.map((w) => w?.id)
.filter(
(id): id is string =>
typeof id === "string" && !knownWidgetIds.has(id),
);
if (droppedIds.length > 0) {
widgetsBody.widgets = widgetsBody.widgets.filter(
(w) => !(typeof w?.id === "string" && !knownWidgetIds.has(w.id)),
);
// v1.7.0 — the unknown-id filter runs over the FULL incoming
// `widgets` array BEFORE Zod's `.max(20)` applies, so `droppedIds`
// is bounded only by the request body limit. Cap the logged array
// (keep the full `dropped_count`) so a single large request can't
// push thousands of strings into one wide-event line.
annotate({
action: { name: "dashboard.widgets.unknown-id-dropped" },
meta: {
dropped_ids: droppedIds.slice(0, 20),
dropped_count: droppedIds.length,
},
});
}
}
const parsed = layoutSchema.safeParse(body);
if (!parsed.success) {
// v1.4.42 W2 — the legacy `issues[0].message` envelope dropped
+11
View File
@@ -58,6 +58,11 @@ const deviceSchema = z
.regex(/^[A-Fa-f0-9]+$/, "apnsToken must be hex")
.optional(),
apnsEnvironment: z.enum(["sandbox", "production"]).optional(),
// v1.7.0 — per-device medication-delivery override. NULL (or
// omitted) inherits the user-level roaming default. "server" forces
// server APNs for this device; "client" forces local. Stored +
// echoed; cron suppression stays user-level.
medicationDelivery: z.enum(["server", "client"]).nullable().optional(),
})
.refine(
(v) =>
@@ -138,6 +143,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
model,
apnsToken,
apnsEnvironment,
medicationDelivery,
} = parsed.data;
// APNs-token collision lookup. Two cases:
@@ -201,6 +207,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
model: model ?? null,
apnsToken: apnsToken ?? null,
apnsEnvironment: apnsEnvironment ?? null,
// v1.7.0 — only touch the override when the client sends the
// field, so a re-register that omits it keeps the prior value.
...(medicationDelivery !== undefined && { medicationDelivery }),
lastSeen: new Date(),
},
select: { id: true },
@@ -218,6 +227,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
model: model ?? null,
apnsToken: apnsToken ?? null,
apnsEnvironment: apnsEnvironment ?? null,
// v1.7.0 — per-device delivery override (null = inherit default).
...(medicationDelivery !== undefined && { medicationDelivery }),
},
select: { id: true },
});
@@ -83,10 +83,20 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
}),
prisma.moodEntry.count({
where: { userId: user.id, moodLoggedAt: { gte: start, lte: end } },
// v1.7.0 sync — exclude tombstoned rows from the availability count.
where: {
userId: user.id,
deletedAt: null,
moodLoggedAt: { gte: start, lte: end },
},
}),
prisma.medicationIntakeEvent.count({
where: { userId: user.id, scheduledFor: { gte: start, lte: end } },
// v1.7.0 sync — exclude tombstoned rows from the availability count.
where: {
userId: user.id,
deletedAt: null,
scheduledFor: { gte: start, lte: end },
},
}),
prisma.measurement.count({
where: {
+5 -2
View File
@@ -63,12 +63,15 @@ export const GET = apiHandler(async (request: NextRequest) => {
},
}),
prisma.medicationIntakeEvent.findMany({
where: { userId: user.id },
// v1.7.0 sync — exclude tombstoned rows (mirrors the measurement
// `deletedAt: null` filter above on this user-facing export).
where: { userId: user.id, deletedAt: null },
include: { medication: { select: { name: true } } },
orderBy: { scheduledFor: "desc" },
}),
prisma.moodEntry.findMany({
where: { userId: user.id },
// v1.7.0 sync — exclude tombstoned rows.
where: { userId: user.id, deletedAt: null },
orderBy: { moodLoggedAt: "desc" },
}),
]);
@@ -0,0 +1,152 @@
/**
* v1.7.0 — POST /api/export/health-record unit coverage.
*
* Mock-based (no testcontainers): pins the route contract — strict Zod
* rejection paths (422 via returnAllZodIssues), rate-limit (429), the
* three format outputs (PDF magic bytes, application/fhir+json valid
* Bundle, application/zip), and that the user is narrowed from the
* session (never the body).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
measurement: { findMany: vi.fn() },
medication: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
user: { findUnique: vi.fn(), update: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/geo", () => ({ lookupIpLocation: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitStructuredLog: vi.fn() }));
vi.mock("@/lib/tz/resolver", () => ({
resolveUserTimezone: vi.fn().mockResolvedValue("Europe/Berlin"),
}));
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
const SESSION_OK = {
user: { id: "user-1", email: "test@example.com", role: "USER" },
} as const;
function mkReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/export/health-record", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: Date.now() + 3_600_000,
} as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] 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);
// First findUnique = aggregator profile select; second = route KVNR select.
vi.mocked(prisma.user.findUnique).mockResolvedValue({
username: "sample",
dateOfBirth: null,
gender: null,
heightCm: null,
glucoseUnit: null,
thresholdsJson: null,
fullName: null,
insurerName: null,
insuranceNumberEncrypted: null,
insightsCachedText: null,
} as never);
});
describe("POST /api/export/health-record — validation", () => {
it("rejects an unknown format with 422", async () => {
const { POST } = await import("../route");
const res = await POST(mkReq({ format: "xml" }));
expect(res.status).toBe(422);
});
it("rejects a missing format with 422", async () => {
const { POST } = await import("../route");
const res = await POST(mkReq({}));
expect(res.status).toBe(422);
});
it("rejects a userId smuggled into the body with 422", async () => {
const { POST } = await import("../route");
const res = await POST(mkReq({ format: "pdf", userId: "user-2" }));
expect(res.status).toBe(422);
});
it("returns 429 when the export rate limit is exhausted", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
remaining: 0,
resetAt: Date.now() + 3_600_000,
} as never);
const { POST } = await import("../route");
const res = await POST(mkReq({ format: "fhir" }));
expect(res.status).toBe(429);
});
});
describe("POST /api/export/health-record — outputs", () => {
it("format=fhir returns a valid FHIR document Bundle", async () => {
const { POST } = await import("../route");
const res = await POST(mkReq({ format: "fhir" }));
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain("application/fhir+json");
const bundle = await res.json();
expect(bundle.resourceType).toBe("Bundle");
expect(bundle.type).toBe("document");
expect(bundle.entry[0].resource.resourceType).toBe("Composition");
});
it("format=pdf returns a PDF with the %PDF- magic bytes", async () => {
const { POST } = await import("../route");
const res = await POST(mkReq({ format: "pdf" }));
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toBe("application/pdf");
const buf = Buffer.from(await res.arrayBuffer());
expect(buf.subarray(0, 5).toString("latin1")).toBe("%PDF-");
});
it("format=package returns a zip", async () => {
const { POST } = await import("../route");
const res = await POST(mkReq({ format: "package" }));
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toBe("application/zip");
const buf = Buffer.from(await res.arrayBuffer());
// ZIP local-file-header magic: PK\x03\x04
expect(buf.subarray(0, 2).toString("latin1")).toBe("PK");
});
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));
// userId smuggle is rejected above; here we confirm the read uses the
// session user, not anything from the body, for a clean payload.
await POST(mkReq({ format: "fhir" }));
expect(prisma.measurement.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ userId: "user-1", deletedAt: null }),
}),
);
});
});
+215
View File
@@ -0,0 +1,215 @@
/**
* POST /api/export/health-record — v1.7.0 flagship health-record export.
*
* One entry point that produces the doctor-handover artefact in one of three
* formats, driven by a strict Zod selection payload:
* - `pdf` → `application/pdf` (enhanced clinical report)
* - `fhir` → `application/fhir+json` (HL7 FHIR R4 document Bundle)
* - `package` → `application/zip` (PDF + FHIR + README in one download)
*
* Auth: cookie session OR Bearer token (`requireAuth`).
* Rate-limit: shared `export:<userId>` bucket (10/h) — so structured + PDF
* + CSV exports cannot be parallelised past the cap.
* Audit: `health-record.export` (records format/days/sections/charts — never
* the values).
*
* Strict validation: unlike the legacy doctor-report route (which tolerates
* drift), this route fails loudly — `.strict()` + `returnAllZodIssues` returns
* a 422 multi-issue envelope on any unknown key (including any attempt to
* smuggle a `userId`, which is always narrowed from `requireAuth()`).
*/
import { NextRequest, NextResponse } from "next/server";
import { zipSync, strToU8 } from "fflate";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
import {
collectDoctorReportData,
normaliseDateRange,
sanitisePracticeName,
} from "@/lib/doctor-report-data";
import { renderDoctorReportPdfBytes } from "@/lib/doctor-report-pdf-core";
import { buildFhirDocumentBundle } from "@/lib/fhir/build-bundle";
import {
exportSelectionSchema,
toDoctorReportPrefs,
} 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 { resolveUserTimezone } from "@/lib/tz/resolver";
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "export.health-record.build" } });
const rl = await checkRateLimit(`export:${user.id}`, 10, 60 * 60 * 1000);
if (!rl.allowed) {
return apiError("Maximum 10 exports per hour", 429);
}
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const parsed = exportSelectionSchema.safeParse(body);
if (!parsed.success) {
return returnAllZodIssues(parsed.error);
}
const selection = parsed.data;
const range = normaliseDateRange(selection.range ?? undefined);
const practiceName = sanitisePracticeName(selection.practiceName);
const sections = toDoctorReportPrefs(selection.sections);
const includeCharts = selection.includeCharts ?? true;
const locale: Locale =
selection.locale ??
parseLocaleFromAcceptLanguage(
request.headers.get("accept-language") ?? "",
);
const [data, userTz, userRow] = await Promise.all([
collectDoctorReportData(user.id, range, { practiceName, sections }),
resolveUserTimezone(user.id),
prisma.user.findUnique({
where: { id: user.id },
select: { insuranceNumberEncrypted: true, insightsCachedText: true },
}),
]);
// Decrypt the KVNR fail-soft: a key-rotation gap on one row should
// never 500 the export — the cover/identifier just omits the value.
let insuranceNumber: string | null = null;
if (userRow?.insuranceNumberEncrypted) {
try {
insuranceNumber = decrypt(userRow.insuranceNumberEncrypted);
} catch {
insuranceNumber = null;
}
}
// AI summary is OUT of the clinical PDF by default — only included when
// the user explicitly toggled `includeAiSummary` AND a cached briefing
// text exists. Never reaches the FHIR bundle (not a clinical observation).
const aiSummary =
selection.includeAiSummary && userRow?.insightsCachedText
? extractAiSummaryText(userRow.insightsCachedText)
: null;
const stamp = new Date().toISOString().slice(0, 10);
await auditLog("health-record.export", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
format: selection.format,
days: range.days,
startDate: range.start.toISOString(),
endDate: range.end.toISOString(),
charts: includeCharts,
aiSummary: aiSummary !== null,
// Section toggles only (booleans) — never the underlying values.
sections,
},
});
if (selection.format === "fhir") {
const bundle = buildFhirDocumentBundle(data, { insuranceNumber });
const json = JSON.stringify(bundle);
annotate({
meta: { format: "fhir", bytes: json.length, days: range.days },
});
return new NextResponse(json, {
status: 200,
headers: {
"Content-Type": "application/fhir+json; charset=utf-8",
"Content-Disposition": `attachment; filename="healthlog-fhir-${stamp}.json"`,
"Cache-Control": "no-store",
},
});
}
const { t } = getServerTranslator(locale);
const pdfBytes = renderDoctorReportPdfBytes(data, {
t,
locale,
userTz,
insuranceNumber,
includeCharts,
aiSummary,
});
if (selection.format === "pdf") {
annotate({
meta: { format: "pdf", bytes: pdfBytes.byteLength, days: range.days },
});
const buffer = pdfBytes.slice().buffer;
return new NextResponse(buffer, {
status: 200,
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="healthlog-report-${stamp}.pdf"`,
"Content-Length": String(pdfBytes.byteLength),
"Cache-Control": "no-store",
},
});
}
// format === "package": one zip holding the PDF + FHIR Bundle + a README.
const bundle = buildFhirDocumentBundle(data, { insuranceNumber });
const readme = t("doctorReport.packageReadme");
const zipped = zipSync(
{
"report.pdf": pdfBytes,
"bundle.json": strToU8(JSON.stringify(bundle, null, 2)),
"README.txt": strToU8(readme),
},
{ level: 6 },
);
annotate({
meta: { format: "package", bytes: zipped.byteLength, days: range.days },
});
const zipBuffer = zipped.slice().buffer;
return new NextResponse(zipBuffer, {
status: 200,
headers: {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="healthlog-health-record-${stamp}.zip"`,
"Content-Length": String(zipped.byteLength),
"Cache-Control": "no-store",
},
});
});
/**
* Extract plain summary text from the cached insights JSON blob. The cache
* is provider-shaped JSON; we pull the human-readable summary field if
* present, else fall back to a trimmed stringification. Always plain text
* (no markdown rendering — the PDF prints React-free string children).
*/
function extractAiSummaryText(raw: string): string | null {
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
const candidate =
(typeof parsed.summary === "string" && parsed.summary) ||
(typeof parsed.text === "string" && parsed.text) ||
(typeof parsed.briefing === "string" && parsed.briefing) ||
null;
if (candidate) return candidate.trim().slice(0, 4000);
return null;
} catch {
// Not JSON — treat as plain text.
const trimmed = raw.trim();
return trimmed.length > 0 ? trimmed.slice(0, 4000) : null;
}
}
+3
View File
@@ -79,9 +79,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
if (includeIntake) {
const where: {
userId: string;
deletedAt: null;
scheduledFor?: { gte?: Date; lte?: Date };
} = {
userId: user.id,
// v1.7.0 sync — exclude tombstoned rows from the export.
deletedAt: null,
};
if (since || until) {
where.scheduledFor = {};
+3
View File
@@ -41,9 +41,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
const where: {
userId: string;
deletedAt: null;
moodLoggedAt?: { gte?: Date; lte?: Date };
} = {
userId: user.id,
// v1.7.0 sync — exclude tombstoned rows from the export.
deletedAt: null,
};
if (since || until) {
where.moodLoggedAt = {};
+5 -2
View File
@@ -81,7 +81,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
if (type === "intake" || type === "all") {
const events = await prisma.medicationIntakeEvent.findMany({
where: { userId },
// v1.7.0 sync — exclude tombstoned rows from the export (mirrors
// the measurement export's `deletedAt: null` filter above).
where: { userId, deletedAt: null },
include: { medication: { select: { name: true } } },
orderBy: { scheduledFor: "desc" },
});
@@ -93,7 +95,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
if (type === "mood" || type === "all") {
const moodEntries = await prisma.moodEntry.findMany({
where: { userId },
// v1.7.0 sync — exclude tombstoned rows from the export.
where: { userId, deletedAt: null },
orderBy: { moodLoggedAt: "desc" },
});
data.moodEntries =
@@ -550,6 +550,8 @@ async function buildAchievementsResult(user: AuthedUser) {
prisma.medicationIntakeEvent.findMany({
where: {
userId,
// v1.7.0 sync — exclude tombstoned rows from streak counting.
deletedAt: null,
source: { not: "IMPORT" },
scheduledFor: { gte: startDate, lte: now },
},
@@ -611,6 +613,8 @@ async function buildAchievementsResult(user: AuthedUser) {
prisma.moodEntry.findMany({
where: {
userId,
// v1.7.0 sync — exclude tombstoned rows from streak counting.
deletedAt: null,
moodLoggedAt: { gte: startDate, lte: now },
},
select: {
+2
View File
@@ -140,6 +140,8 @@ export const GET = apiHandler(async () => {
const allEvents = await prisma.medicationIntakeEvent.findMany({
where: {
userId: user.id,
// v1.7.0 sync — exclude tombstoned rows.
deletedAt: null,
medicationId: { in: activeMeds.map((m) => m.id) },
scheduledFor: { gte: new Date(Date.now() - 30 * 86_400_000) },
},
+4
View File
@@ -443,6 +443,10 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
promptVersion: PROMPT_VERSION,
conversationId: workingConversationId,
historyTurns: window.length,
// v1.7.0 — count of provenance metrics the snapshot surfaced
// this turn (a proxy for cluster breadth) so the dashboards can
// correlate reply shape with cluster activation.
clusterCount: snapshot.provenance.metrics.length,
},
});
+21 -4
View File
@@ -15,7 +15,11 @@ import {
pearsonCorrelation,
type PairedPoint,
} from "@/lib/analytics/correlations";
import { calculateCompliance } from "@/lib/analytics/compliance";
import {
buildComplianceMedicationContext,
calculateCompliance,
lastNonSkippedTakenAt,
} from "@/lib/analytics/compliance";
import { getMedicationCategories } from "@/lib/medication-category";
import { apiHandler, requireAuth, type AuthContext } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
@@ -126,7 +130,8 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
// rollup coverage yet. Bounded by the 90-day window so the walk is
// capped even when the rollup miss happens.
const moodEntries = await prisma.moodEntry.findMany({
where: { userId, moodLoggedAt: { gte: ninetyDaysAgo } },
// v1.7.0 sync — exclude tombstoned rows.
where: { userId, deletedAt: null, moodLoggedAt: { gte: ninetyDaysAgo } },
orderBy: { moodLoggedAt: "asc" },
select: { date: true, score: true, moodLoggedAt: true },
});
@@ -273,6 +278,8 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
where: {
medicationId: { in: medications.map((m) => m.id) },
userId,
// v1.7.0 sync — exclude tombstoned rows.
deletedAt: null,
scheduledFor: { gte: ninetyDaysAgo },
},
orderBy: { scheduledFor: "desc" },
@@ -293,8 +300,18 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
skipped: e.skipped,
scheduledFor: e.scheduledFor,
}));
const c7 = calculateCompliance(mapped, med.schedules, 7, med.createdAt);
const c30 = calculateCompliance(mapped, med.schedules, 30, med.createdAt);
// v1.7.0 SB-SCHED-2 — engine-routed denominator.
const medicationContext = buildComplianceMedicationContext(
med,
lastNonSkippedTakenAt(mapped),
user.timezone || "Europe/Berlin",
);
const c7 = calculateCompliance(mapped, med.schedules, 7, med.createdAt, {
medicationContext,
});
const c30 = calculateCompliance(mapped, med.schedules, 30, med.createdAt, {
medicationContext,
});
medCompliance.push({
id: med.id,
+6
View File
@@ -33,6 +33,7 @@ import { checkRateLimit } from "@/lib/rate-limit";
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { invalidateUserInsights } from "@/lib/cache/invalidate";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
@@ -541,6 +542,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
// still show yesterday's text until midnight Berlin time.
await evictPerStatusInsightCache(userId);
// v1.7.0 W6 — the dashboard snapshot embeds the pre-generated daily
// briefing read-only; drop it so the next snapshot carries the fresh
// briefing instead of the stale one.
invalidateUserInsights(userId);
await auditLog("insights.generate", {
userId,
ipAddress: getClientIp(request),
+2 -1
View File
@@ -93,7 +93,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
const ninetyDaysAgo = new Date();
ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90);
const moods = await prisma.moodEntry.findMany({
where: { userId: user.id, moodLoggedAt: { gte: ninetyDaysAgo } },
// v1.7.0 sync — exclude tombstoned rows.
where: { userId: user.id, deletedAt: null, moodLoggedAt: { gte: ninetyDaysAgo } },
select: { moodLoggedAt: true, tags: true },
orderBy: { moodLoggedAt: "desc" },
});
+20 -3
View File
@@ -11,7 +11,11 @@ import {
getStepsRange,
getBpTargetsByAge,
} from "@/lib/analytics/classifications";
import { calculateCompliance } from "@/lib/analytics/compliance";
import {
buildComplianceMedicationContext,
calculateCompliance,
lastNonSkippedTakenAt,
} from "@/lib/analytics/compliance";
import { pairByTimestamp } from "@/lib/analytics/correlations";
import { isBpReadingInTarget } from "@/lib/analytics/bp-in-target";
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
@@ -872,6 +876,8 @@ async function buildTargetsResponse(user: AuthedUser) {
const intakeEvents = await prisma.medicationIntakeEvent.findMany({
where: {
userId,
// v1.7.0 sync — exclude tombstoned rows.
deletedAt: null,
medicationId: {
in: activeMedications.map((medication) => medication.id),
},
@@ -890,17 +896,26 @@ async function buildTargetsResponse(user: AuthedUser) {
const medicationEvents = intakeEvents.filter(
(event) => event.medicationId === medication.id,
);
// v1.7.0 SB-SCHED-2 — engine-routed denominator (userTz resolved
// above at the route entry).
const medicationContext = buildComplianceMedicationContext(
medication,
lastNonSkippedTakenAt(medicationEvents),
userTz,
);
const compliance7 = calculateCompliance(
medicationEvents,
medication.schedules,
7,
medication.createdAt,
{ medicationContext },
);
const compliance30 = calculateCompliance(
medicationEvents,
medication.schedules,
30,
medication.createdAt,
{ medicationContext },
);
return {
@@ -1038,7 +1053,8 @@ async function buildTargetsResponse(user: AuthedUser) {
const moodSince = new Date(Date.now() - moodRollupWindowMs);
const moodRollups = await readMoodDayRollups(userId, moodSince);
const latestMoodEntry = await prisma.moodEntry.findFirst({
where: { userId },
// v1.7.0 sync — exclude tombstoned rows.
where: { userId, deletedAt: null },
orderBy: { moodLoggedAt: "desc" },
select: { score: true, moodLoggedAt: true },
});
@@ -1064,7 +1080,8 @@ async function buildTargetsResponse(user: AuthedUser) {
// trailing 30-day window only (instead of every entry ever) \u2014 that's
// the bound the legacy code was missing.
const recentRawMood = await prisma.moodEntry.findMany({
where: { userId, moodLoggedAt: { gte: thirtyDaysAgoMood } },
// v1.7.0 sync — exclude tombstoned rows.
where: { userId, deletedAt: null, moodLoggedAt: { gte: thirtyDaysAgoMood } },
orderBy: { moodLoggedAt: "asc" },
select: { score: true, moodLoggedAt: true },
});
@@ -21,7 +21,8 @@ export const GET = apiHandler(async () => {
});
const entryCount = await prisma.moodEntry.count({
where: { userId: user.id },
// v1.7.0 sync — exclude tombstoned rows from the entry count.
where: { userId: user.id, deletedAt: null },
});
// V3 audit STILL-V2-C-2: stored secret is now AES-GCM encrypted at rest.
@@ -7,7 +7,7 @@ import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
measurement: {
findUnique: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(),
},
auditLog: { create: vi.fn() },
@@ -60,7 +60,7 @@ beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
vi.mocked(prisma.measurement.findUnique).mockResolvedValue({
vi.mocked(prisma.measurement.findFirst).mockResolvedValue({
id: "m1",
userId: "user-1",
} as never);
+26 -5
View File
@@ -24,8 +24,12 @@ export const GET = apiHandler(
const { id } = await params;
const measurement = await prisma.measurement.findUnique({
where: { id },
// v1.7.0 — filter `deletedAt: null` so a soft-deleted (tombstoned)
// row 404s on a direct GET, matching the list / analytics / rollup
// read invariant. `findFirst` (not `findUnique`) because `deletedAt`
// is not part of a unique index.
const measurement = await prisma.measurement.findFirst({
where: { id, deletedAt: null },
});
if (!measurement || measurement.userId !== user.id) {
@@ -50,8 +54,11 @@ export const PUT = apiHandler(
const { id } = await params;
const existing = await prisma.measurement.findUnique({
where: { id },
// v1.7.0 — refuse to resurrect-edit a tombstoned row. The
// `deletedAt: null` filter makes a soft-deleted measurement 404 on
// PUT rather than letting an `update` re-write a still-tombstoned row.
const existing = await prisma.measurement.findFirst({
where: { id, deletedAt: null },
});
if (!existing || existing.userId !== user.id) {
@@ -183,7 +190,21 @@ export const DELETE = apiHandler(
return apiError("Measurement not found", 404);
}
await prisma.measurement.delete({ where: { id } });
// v1.7.0 — soft-delete instead of a hard `delete`. Setting `deletedAt`
// (+ bumping `syncVersion`) leaves the row in place so the
// `/api/sync/changes` delta feed can surface it as a tombstone to
// paired clients that were offline at delete time. Every list /
// analytics / rollup read already filters `deletedAt: null`
// (see `measurements/route.ts:100`), so the row is invisible to
// normal reads from this point on. A row that is already tombstoned
// re-bumps `syncVersion` harmlessly (idempotent re-delete).
await prisma.measurement.update({
where: { id },
data: {
deletedAt: new Date(),
syncVersion: { increment: 1 },
},
});
await auditLog("measurement.delete", {
userId: user.id,
@@ -4,7 +4,7 @@ import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
measurement: {
findUnique: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(),
},
},
@@ -54,7 +54,7 @@ beforeEach(() => {
describe("PUT /api/measurements/[id] — duplicate-timestamp handling", () => {
it("returns 409 when re-pointing measuredAt onto an existing tuple", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.measurement.findUnique).mockResolvedValue({
vi.mocked(prisma.measurement.findFirst).mockResolvedValue({
id: "m-2",
userId: "user-1",
type: "ACTIVITY_STEPS",
@@ -90,7 +90,7 @@ describe("PUT /api/measurements/[id] — duplicate-timestamp handling", () => {
it("re-throws any non-P2002 Prisma error", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.measurement.findUnique).mockResolvedValue({
vi.mocked(prisma.measurement.findFirst).mockResolvedValue({
id: "m-3",
userId: "user-1",
type: "WEIGHT",
@@ -126,7 +126,7 @@ describe("PUT /api/measurements/[id] — duplicate-timestamp handling", () => {
// PUT exists — workouts are ingested via POST /api/workouts/batch
// only. The 409 handler must therefore fire for sport-typed rows.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.measurement.findUnique).mockResolvedValue({
vi.mocked(prisma.measurement.findFirst).mockResolvedValue({
id: "m-sport",
userId: "user-1",
type: "ACTIVE_ENERGY_BURNED",
@@ -163,7 +163,7 @@ describe("PUT /api/measurements/[id] — duplicate-timestamp handling", () => {
it("returns the updated measurement on the 200 happy path", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.measurement.findUnique).mockResolvedValue({
vi.mocked(prisma.measurement.findFirst).mockResolvedValue({
id: "m-4",
userId: "user-1",
type: "WEIGHT",
@@ -5,14 +5,17 @@
* iOS client performs a periodic 30-day window reconciliation: it pulls
* the current set of HealthKit sample UUIDs in the window and posts the
* externalIds the server holds that are no longer present. This endpoint
* accepts that batch-delete list and removes every matching row owned by
* the calling user.
* accepts that batch-delete list and soft-deletes every matching row owned
* by the calling user (v1.7.0 — sets `deletedAt` + bumps `syncVersion`
* rather than removing the row, so deletions surface as tombstones on the
* `/api/sync/changes` delta feed).
*
* Idempotency contract:
* - Cross-user safety: rows owned by another user are silently skipped
* (Prisma's `deleteMany` with `userId` in the where-clause guarantees
* (Prisma's `updateMany` with `userId` in the where-clause guarantees
* this), so a duplicate / replayed delete is a no-op rather than a
* 401.
* 401. The `deletedAt: null` guard makes a replay touch zero rows
* and return `deletedCount: 0`.
* - Empty arrays return `{ deletedCount: 0 }` with 200 — the iOS
* client should not consider that a failure.
* - The batch cap mirrors the ingest path (`/api/measurements/batch`)
@@ -85,24 +88,38 @@ async function deleteByExternalIds(request: NextRequest): Promise<Response> {
return apiSuccess({ deletedCount: 0 });
}
// Prisma `deleteMany` with the userId predicate is the cross-user
// 404 guard: rows belonging to other users are simply not matched.
// We pre-fetch the (type, measuredAt) tuples so the rollup recompute
// step below knows which buckets to refresh after the deletion.
// The unique index on `(userId, type, source, externalId)` keeps
// the matched set small even with overlapping HealthKit history.
// v1.7.0 — soft-delete instead of a hard `deleteMany`. Flipping
// `deletedAt` (+ bumping `syncVersion`) keeps the row so the
// `/api/sync/changes` delta feed surfaces it as a tombstone keyed on
// `externalId` for paired clients that were offline at delete time.
// Every list / analytics / rollup read already filters
// `deletedAt: null`, so a tombstoned row is invisible to normal reads.
//
// The userId predicate stays the cross-user 404 guard: rows belonging
// to other users are simply not matched. The `deletedAt: null` guard
// makes a replayed reconciliation idempotent — already-tombstoned rows
// are not re-touched and `deletedCount` counts only rows newly
// tombstoned by this call. We pre-fetch the (type, measuredAt) tuples
// of the live matches so the rollup recompute below knows which
// buckets to refresh.
const affectedRows = await prisma.measurement.findMany({
where: {
userId: user.id,
externalId: { in: externalIds },
deletedAt: null,
},
select: { type: true, measuredAt: true },
});
const result = await prisma.measurement.deleteMany({
const result = await prisma.measurement.updateMany({
where: {
userId: user.id,
externalId: { in: externalIds },
deletedAt: null,
},
data: {
deletedAt: new Date(),
syncVersion: { increment: 1 },
},
});
+31 -1
View File
@@ -28,8 +28,10 @@ import {
import {
buildCadenceTimeline,
computeNextDose,
type CadenceEngineContext,
} from "@/lib/medications/scheduling/cadence";
import { complianceChips } from "@/lib/medications/scheduling/compliance";
import { lastNonSkippedTakenAt } from "@/lib/analytics/compliance";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { resolveUserTimezone } from "@/lib/tz/resolver";
@@ -75,6 +77,8 @@ export const GET = apiHandler(
where: {
medicationId: id,
userId: user.id,
// v1.7.0 sync — exclude tombstoned rows from the cadence read.
deletedAt: null,
scheduledFor: { gte: from },
},
select: { scheduledFor: true, takenAt: true, skipped: true },
@@ -95,6 +99,23 @@ export const GET = apiHandler(
// was a sneaky bug at the 08:00-Berlin / 16:00-Tokyo intersection.
const userTz = await resolveUserTimezone(user.id);
// v1.7.0 SB-SCHED-2 — route the detail-page compliance through the
// canonical recurrence engine so RRULE / rolling / cyclic / one-shot /
// PRN schedules read the same expected-slot grid the dashboard tile +
// intake path use. Without this the detail page falls back to the
// legacy weekday walker (e.g. `FREQ=WEEKLY;BYDAY=MO` expands daily,
// PRN counts as a real cadence) and disagrees with the dashboard.
// Mirrors `analytics/compliance.ts`'s context construction.
const lastIntakeAt = lastNonSkippedTakenAt(events);
const engineCtx: CadenceEngineContext = {
startsOn: med.startsOn,
endsOn: med.endsOn,
oneShot: med.oneShot,
createdAt: med.createdAt,
lastIntakeAt,
timeZone: userTz,
};
const timeline = buildCadenceTimeline(
med.schedules,
events,
@@ -102,6 +123,7 @@ export const GET = apiHandler(
windowDays,
anchor,
userTz,
engineCtx,
);
const chips = complianceChips(
med.schedules,
@@ -110,8 +132,16 @@ export const GET = apiHandler(
windowDays,
anchor,
userTz,
engineCtx,
);
const next = computeNextDose(
med.schedules,
asOf,
14,
anchor,
userTz,
engineCtx,
);
const next = computeNextDose(med.schedules, asOf, 14, anchor, userTz);
annotate({
action: {
@@ -3,11 +3,16 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, apiError } from "@/lib/api-response";
import {
buildComplianceMedicationContext,
calculateCompliance,
classifyIntakeTiming,
expectedSlotCountForDay,
lastNonSkippedTakenAt,
} from "@/lib/analytics/compliance";
import type { DailyComplianceEntry } from "@/lib/analytics/compliance";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { getUserTodayBounds } from "@/lib/timezone";
import { userDayKey } from "@/lib/tz/format";
type RouteParams = { params: Promise<{ id: string }> };
@@ -30,7 +35,8 @@ export const GET = apiHandler(
}
const events = await prisma.medicationIntakeEvent.findMany({
where: { medicationId: id, userId: user.id },
// v1.7.0 sync — exclude tombstoned rows from the compliance read.
where: { medicationId: id, userId: user.id, deletedAt: null },
orderBy: { scheduledFor: "desc" },
});
@@ -41,32 +47,59 @@ export const GET = apiHandler(
}));
const createdAt = medication.createdAt;
const compliance7 = calculateCompliance(
mapped,
medication.schedules,
7,
createdAt,
// v1.7.0 SB-SCHED-2 — thread the medication context so the
// denominator routes through the canonical engine (RRULE / rolling /
// one-shot / PRN / cyclic) instead of the legacy daysOfWeek walker.
// `lastIntakeAt` is the latest non-skipped takenAt (rolling cadences
// re-anchor on it); the events list is already ordered scheduledFor
// desc, so scan for the max takenAt.
const lastIntakeAt = lastNonSkippedTakenAt(mapped);
const userTz = user.timezone || "Europe/Berlin";
const medicationContext = buildComplianceMedicationContext(
medication,
lastIntakeAt,
userTz,
);
const compliance7 = calculateCompliance(mapped, medication.schedules, 7, createdAt, {
medicationContext,
});
const compliance30 = calculateCompliance(
mapped,
medication.schedules,
30,
createdAt,
{ medicationContext },
);
// Build daily compliance map for heatmap/line chart (90 days)
const now = new Date();
const schedulesPerDay = medication.schedules.length;
const dailyCompliance: Record<string, DailyComplianceEntry> = {};
for (let d = 0; d < 90; d++) {
const dayStart = new Date(now.getTime() - (d + 1) * 24 * 60 * 60 * 1000);
const dayEnd = new Date(now.getTime() - d * 24 * 60 * 60 * 1000);
// v1.7.0 code-correctness M1 — anchor each day's [start,end) on the
// user-tz local-day boundary, not a UTC-midnight slice. The engine
// applies `timesOfDay` in the user timezone, so for tz-distant users
// a UTC slice could attach `due` / `expectedCount` to the adjacent
// calendar cell. Anchor on a noon-local representative instant
// (dodges the DST midnight-ambiguity edge) and key off the user-tz
// day so the heatmap cell, the engine frame, and `dateKey` agree.
const representative = new Date(
now.getTime() - d * 24 * 60 * 60 * 1000 - 12 * 60 * 60 * 1000,
);
const { start: dayStart, end: dayEndInclusive } = getUserTodayBounds(
representative,
userTz,
);
// `getUserTodayBounds` returns an inclusive end (local 23:59:59.999);
// the slicing loops below use a half-open `[dayStart, dayEnd)`.
const dayEnd = new Date(dayEndInclusive.getTime() + 1);
// Skip days before medication was created
if (dayEnd <= createdAt) continue;
const dateKey = dayStart.toISOString().slice(0, 10);
const dateKey = userDayKey(dayStart, userTz);
const dayEvents = mapped.filter(
(e) => e.scheduledFor >= dayStart && e.scheduledFor < dayEnd,
@@ -123,8 +156,24 @@ export const GET = apiHandler(
else veryLate++;
}
// v1.7.0 item 5 — the per-day expected count is the engine's actual
// due-slot count for THIS day, not the static schedule count. iOS
// history paints a "missed" mark only when `due === true`, so
// off-weeks / non-matching weekdays / PRN days no longer show a
// false miss. `expected` is kept populated (= expectedCount) for
// existing web consumers that read it; `due` + `expectedCount` are
// the explicit additive fields iOS keys off.
const expectedCount = expectedSlotCountForDay(
medication.schedules,
dayStart,
dayEnd,
medicationContext,
);
dailyCompliance[dateKey] = {
expected: schedulesPerDay,
expected: expectedCount,
expectedCount,
due: expectedCount > 0,
taken: takenEvents.length,
skipped: dayEvents.filter((e) => e.skipped).length,
onTime: onTime + early,
@@ -85,9 +85,16 @@ beforeEach(() => {
vi.mocked(prisma.medication.updateMany).mockResolvedValue({
count: 0,
} as never);
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue(
null as never,
);
// v1.7.0 sync — PUT now looks the event up via `findFirst` with a
// `deletedAt: null` guard. The default returns the live event so the
// PUT lookup succeeds; the lifecycle `liveIntake` probe (also
// `findFirst`) is sequenced per-test via `mockResolvedValueOnce`.
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({
id: "e1",
userId: "user-1",
medicationId: "m1",
scheduledFor: new Date(),
} as never);
});
describe("PUT /api/medications/[id]/intake/[eventId] — 422 multi-issue (v1.4.43 W6)", () => {
@@ -212,9 +219,17 @@ describe("PUT /api/medications/[id]/intake/[eventId] — one-shot reconcile on s
oneShot: true,
active: false,
} as never);
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValueOnce(
null as never,
);
// v1.7.0 sync — first `findFirst` is the PUT event lookup (returns
// the live event); the second is the lifecycle `liveIntake` probe
// (returns null → the dose is no longer logged → reactivate).
vi.mocked(prisma.medicationIntakeEvent.findFirst)
.mockResolvedValueOnce({
id: "e1",
userId: "user-1",
medicationId: "m1",
scheduledFor: new Date(),
} as never)
.mockResolvedValueOnce(null as never);
vi.mocked(prisma.medication.updateMany).mockResolvedValueOnce({
count: 1,
} as never);
@@ -24,8 +24,10 @@ export const PUT = apiHandler(
const { id, eventId } = await params;
const event = await prisma.medicationIntakeEvent.findUnique({
where: { id: eventId },
// v1.7.0 sync — a tombstoned event 404s on PUT; the `deletedAt: null`
// filter refuses to resurrect-edit a soft-deleted intake.
const event = await prisma.medicationIntakeEvent.findFirst({
where: { id: eventId, deletedAt: null },
});
if (!event || event.userId !== user.id || event.medicationId !== id) {
@@ -76,6 +78,10 @@ export const PUT = apiHandler(
...(data.scheduledFor !== undefined && {
scheduledFor: data.scheduledFor,
}),
// v1.7.0 sync — bump the reconciliation counter on every
// server-side mutation so the `/api/sync/changes` feed echoes a
// monotonic value to paired clients.
syncVersion: { increment: 1 },
},
});
@@ -144,7 +150,21 @@ export const DELETE = apiHandler(
return apiError("Intake not found", 404);
}
await prisma.medicationIntakeEvent.delete({ where: { id: eventId } });
// v1.7.0 sync — soft-delete instead of a hard `delete`. An intake is
// an immutable fact, so a "correction" is a tombstone + re-insert
// (never an in-place edit). Setting `deletedAt` (+ bumping
// `syncVersion`) leaves the row in place so the `/api/sync/changes`
// feed surfaces the deletion as a tombstone keyed on the server `id`
// to paired clients offline at delete time. Every today / compliance
// / list read filters `deletedAt: null`, so the row is invisible to
// normal reads from here on. A re-delete re-bumps harmlessly.
await prisma.medicationIntakeEvent.update({
where: { id: eventId },
data: {
deletedAt: new Date(),
syncVersion: { increment: 1 },
},
});
const ip = getClientIp(request) ?? "unknown";
await auditLog("medication.intake.delete", {
@@ -122,7 +122,12 @@ describe("GET /api/medications/[id]/intake — status filter", () => {
const where = vi.mocked(prisma.medicationIntakeEvent.findMany).mock
.calls[0][0]?.where;
expect(where).toEqual({ medicationId: "med-1", userId: "user-1" });
// v1.7.0 sync — list filters out tombstoned rows.
expect(where).toEqual({
medicationId: "med-1",
userId: "user-1",
deletedAt: null,
});
});
it("applies no status filter when explicitly passed `status=all`", async () => {
@@ -134,7 +139,11 @@ describe("GET /api/medications/[id]/intake — status filter", () => {
const where = vi.mocked(prisma.medicationIntakeEvent.findMany).mock
.calls[0][0]?.where;
expect(where).toEqual({ medicationId: "med-1", userId: "user-1" });
expect(where).toEqual({
medicationId: "med-1",
userId: "user-1",
deletedAt: null,
});
});
it("filters to confirmed-taken rows for `status=taken`", async () => {
@@ -144,6 +153,7 @@ describe("GET /api/medications/[id]/intake — status filter", () => {
expect(where).toEqual({
medicationId: "med-1",
userId: "user-1",
deletedAt: null,
takenAt: { not: null },
skipped: false,
});
@@ -156,6 +166,7 @@ describe("GET /api/medications/[id]/intake — status filter", () => {
expect(where).toEqual({
medicationId: "med-1",
userId: "user-1",
deletedAt: null,
skipped: true,
});
});
@@ -167,6 +178,7 @@ describe("GET /api/medications/[id]/intake — status filter", () => {
expect(where).toEqual({
medicationId: "med-1",
userId: "user-1",
deletedAt: null,
OR: [{ takenAt: { not: null }, skipped: false }, { skipped: true }],
});
});
@@ -184,6 +196,7 @@ describe("GET /api/medications/[id]/intake — status filter", () => {
expect(countArgs?.where).toEqual({
medicationId: "med-1",
userId: "user-1",
deletedAt: null,
OR: [{ takenAt: { not: null }, skipped: false }, { skipped: true }],
});
});
+20 -2
View File
@@ -277,12 +277,30 @@ export const GET = apiHandler(
],
}
: {};
const where = { medicationId: id, userId: user.id, ...statusFilter };
// v1.7.0 sync — exclude tombstoned rows from the per-medication
// intake history list + its count.
const where = {
medicationId: id,
userId: user.id,
deletedAt: null,
...statusFilter,
};
// v1.7.0 O-1 — pin NULLS LAST on the `takenAt` sort. Skipped rows
// carry `takenAt: null`; under a bare `desc` collation Postgres
// emits NULLS FIRST, floating skipped/planned rows to the top of
// the history view. Pinning them last keeps the descending order
// reading today → yesterday → … with real timestamps first. Other
// sort columns are non-null so they keep the simple shape.
const orderBy =
sortBy === "takenAt"
? { takenAt: { sort: sortDir, nulls: "last" as const } }
: { [sortBy]: sortDir };
const [events, total] = await Promise.all([
prisma.medicationIntakeEvent.findMany({
where,
orderBy: { [sortBy]: sortDir },
orderBy,
take: limit,
skip: offset,
}),
+57 -1
View File
@@ -17,6 +17,7 @@ import {
setMedicationCategory,
} from "@/lib/medication-category";
import { serializeScheduleRecurrence } from "@/lib/medication-schedule";
import { computeNextDueAt } from "@/lib/medications/scheduling/next-due";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { NextRequest } from "next/server";
@@ -44,6 +45,39 @@ export const GET = apiHandler(
// Category enrichment is optional
}
// v1.7.0 SB-SCHED-3 — server-computed next due instant. Rolling
// cadences re-anchor on the latest non-skipped intake, so fetch it
// once (no-op for calendar cadences but cheap + keeps the value
// correct for rolling injections).
const lastIntake =
medication.schedules.some((s) => s.rollingIntervalDays !== null)
? await prisma.medicationIntakeEvent.findFirst({
// v1.7.0 sync — a tombstoned intake no longer anchors the
// rolling-interval next-due computation.
where: {
userId: user.id,
medicationId: id,
deletedAt: null,
takenAt: { not: null },
},
orderBy: { takenAt: "desc" },
select: { takenAt: true },
})
: null;
const nextDue = computeNextDueAt({
medication: {
id: medication.id,
startsOn: medication.startsOn,
endsOn: medication.endsOn,
oneShot: medication.oneShot,
createdAt: medication.createdAt,
},
schedules: medication.schedules,
now: new Date(),
userTz: user.timezone || "Europe/Berlin",
lastIntakeAt: lastIntake?.takenAt ?? null,
});
annotate({
action: {
name: "medication.get",
@@ -52,7 +86,11 @@ export const GET = apiHandler(
},
});
return apiSuccess({ ...medication, category });
return apiSuccess({
...medication,
category,
nextDueAt: nextDue ? nextDue.toISOString() : null,
});
},
);
@@ -94,6 +132,8 @@ export const PUT = apiHandler(
deliveryForm,
active,
notificationsEnabled,
liveActivityEnabled,
criticalAlarmEnabled,
schedules,
startsOn,
endsOn,
@@ -184,6 +224,9 @@ export const PUT = apiHandler(
...(deliveryForm !== undefined && { deliveryForm }),
...(active !== undefined && { active }),
...(notificationsEnabled !== undefined && { notificationsEnabled }),
// v1.7.0 — iOS reminder flags, field-by-field.
...(liveActivityEnabled !== undefined && { liveActivityEnabled }),
...(criticalAlarmEnabled !== undefined && { criticalAlarmEnabled }),
// v1.5 scheduling primitives — pass-through when supplied.
// `startsOn` / `endsOn` are `Date | null | undefined` (the
// schema lets the user clear them explicitly with null).
@@ -194,9 +237,12 @@ export const PUT = apiHandler(
schedules: {
create: schedules.map((s) => {
// Invariant 2 — default to FREQ=DAILY when nothing else is set.
// v1.7.0 — PRN carries no cadence, so never default it.
const hasLegacyDays = (s.daysOfWeek?.length ?? 0) > 0;
const isPrn = s.scheduleType === "PRN";
const defaultedRrule =
oneShot !== true &&
!isPrn &&
s.rrule === undefined &&
s.rollingIntervalDays === undefined &&
!hasLegacyDays
@@ -227,6 +273,16 @@ export const PUT = apiHandler(
...(s.rollingIntervalDays !== undefined && {
rollingIntervalDays: s.rollingIntervalDays,
}),
// v1.7.0 — schedule type + cyclic weeks, field-by-field.
...(s.scheduleType !== undefined && {
scheduleType: s.scheduleType,
}),
...(s.cyclicOnWeeks !== undefined && {
cyclicOnWeeks: s.cyclicOnWeeks,
}),
...(s.cyclicOffWeeks !== undefined && {
cyclicOffWeeks: s.cyclicOffWeeks,
}),
};
}),
},
@@ -6,6 +6,7 @@ vi.mock("@/lib/db", () => ({
medicationIntakeEvent: {
findMany: vi.fn(),
findUnique: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(),
// v1.4.39 W-SERVER-FIX — `scope=today` now backfills missing
// rows for schedules whose window opens today (covers daily
@@ -328,7 +329,9 @@ describe("POST /api/medications/intake", () => {
it("returns 404 when the event isn't owned by the user", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationIntakeEvent.findUnique).mockResolvedValue({
// v1.7.0 sync — the status toggle looks the row up via `findFirst`
// with a `deletedAt: null` guard.
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({
id: "e1",
userId: "someone-else",
medicationId: "m1",
@@ -345,7 +348,7 @@ describe("POST /api/medications/intake", () => {
it("marks event as skipped", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationIntakeEvent.findUnique).mockResolvedValue({
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({
id: "e1",
userId: "user-1",
medicationId: "m1",
@@ -358,9 +361,10 @@ describe("POST /api/medications/intake", () => {
} as never);
const res = await POST(req({ intakeId: "e1", status: "skipped" }));
expect(res.status).toBe(200);
// v1.7.0 sync — the skip toggle bumps syncVersion.
expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledWith({
where: { id: "e1" },
data: { takenAt: null, skipped: true },
data: { takenAt: null, skipped: true, syncVersion: { increment: 1 } },
});
});
});
+17 -5
View File
@@ -131,6 +131,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
const events = await prisma.medicationIntakeEvent.findMany({
where: {
userId: user.id,
// v1.7.0 sync — exclude tombstoned rows from the today list.
deletedAt: null,
scheduledFor: { gte: todayStart, lt: todayEnd },
},
orderBy: { scheduledFor: "asc" },
@@ -256,7 +258,8 @@ async function buildComplianceBuckets(
const nowMs = Date.now();
const start = new Date(nowMs - days * 86_400_000);
const events = await prisma.medicationIntakeEvent.findMany({
where: { userId, scheduledFor: { gte: start } },
// v1.7.0 sync — exclude tombstoned rows from the compliance buckets.
where: { userId, deletedAt: null, scheduledFor: { gte: start } },
select: { scheduledFor: true, takenAt: true, skipped: true },
});
@@ -313,8 +316,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
const { intakeId, status, takenAt, snoozedUntil } = parsed.data;
const existing = await prisma.medicationIntakeEvent.findUnique({
where: { id: intakeId },
// v1.7.0 sync — a tombstoned intake 404s on a status toggle; the
// `deletedAt: null` filter refuses to mutate a soft-deleted row.
const existing = await prisma.medicationIntakeEvent.findFirst({
where: { id: intakeId, deletedAt: null },
});
if (!existing || existing.userId !== user.id) {
return apiError("Intake event not found", 404);
@@ -327,7 +332,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
[updated] = await prisma.$transaction([
prisma.medicationIntakeEvent.update({
where: { id: intakeId },
data: { takenAt: takenAt ?? new Date(), skipped: false },
// v1.7.0 sync — bump the reconciliation counter on every
// server-side mutation so the delta feed echoes a monotonic value.
data: {
takenAt: takenAt ?? new Date(),
skipped: false,
syncVersion: { increment: 1 },
},
}),
prisma.medication.update({
where: { id: existing.medicationId },
@@ -337,7 +348,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
} else if (status === "skipped") {
updated = await prisma.medicationIntakeEvent.update({
where: { id: intakeId },
data: { takenAt: null, skipped: true },
// v1.7.0 sync — bump the reconciliation counter on the skip toggle.
data: { takenAt: null, skipped: true, syncVersion: { increment: 1 } },
});
} else {
// snoozed: snoozedUntil lives on the Medication row.
+58 -8
View File
@@ -15,6 +15,7 @@ import {
setMedicationCategory,
} from "@/lib/medication-category";
import { serializeScheduleRecurrence } from "@/lib/medication-schedule";
import { computeNextDueAt } from "@/lib/medications/scheduling/next-due";
import { getUserTodayBounds } from "@/lib/timezone";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
@@ -39,12 +40,18 @@ async function buildMedicationsList(
}),
prisma.medicationIntakeEvent.groupBy({
by: ["medicationId"],
where: { userId, skipped: false, takenAt: { not: null } },
// v1.7.0 sync — exclude tombstoned rows from the last-taken map.
where: { userId, deletedAt: null, skipped: false, takenAt: { not: null } },
_max: { takenAt: true },
}),
prisma.medicationIntakeEvent.groupBy({
by: ["medicationId"],
where: { userId, scheduledFor: { gte: todayStartUtc, lte: todayEndUtc } },
// v1.7.0 sync — exclude tombstoned rows from the today-count map.
where: {
userId,
deletedAt: null,
scheduledFor: { gte: todayStartUtc, lte: todayEndUtc },
},
_count: { id: true },
}),
]);
@@ -55,6 +62,11 @@ async function buildMedicationsList(
entry._max.takenAt ? entry._max.takenAt.toISOString() : null,
]),
);
// v1.7.0 SB-SCHED-3 — Date-typed last-intake map for the engine
// (rolling cadences re-anchor on it). Same groupBy as the ISO map.
const lastTakenAtDateByMedicationId = Object.fromEntries(
latestIntakes.map((entry) => [entry.medicationId, entry._max.takenAt]),
);
const todayEventCountByMedId = Object.fromEntries(
todayEvents.map(
(entry: { medicationId: string; _count: { id: number } }) => [
@@ -71,12 +83,33 @@ async function buildMedicationsList(
getEvent()?.addWarning("Medication categories could not be loaded");
}
return medications.map((m) => ({
...m,
category: categoryMap[m.id] ?? "OTHER",
lastTakenAt: lastTakenAtByMedicationId[m.id] ?? null,
todayEventCount: todayEventCountByMedId[m.id] ?? 0,
}));
// v1.7.0 SB-SCHED-3 — server-computed next due instant. Time-derived;
// the list GET is cached 60 s on userId, so a 60 s staleness window is
// accepted here as it already is for `todayEventCount`.
const now = new Date();
return medications.map((m) => {
const nextDue = computeNextDueAt({
medication: {
id: m.id,
startsOn: m.startsOn,
endsOn: m.endsOn,
oneShot: m.oneShot,
createdAt: m.createdAt,
},
schedules: m.schedules,
now,
userTz,
lastIntakeAt: lastTakenAtDateByMedicationId[m.id] ?? null,
});
return {
...m,
category: categoryMap[m.id] ?? "OTHER",
lastTakenAt: lastTakenAtByMedicationId[m.id] ?? null,
todayEventCount: todayEventCountByMedId[m.id] ?? 0,
nextDueAt: nextDue ? nextDue.toISOString() : null,
};
});
}
export const GET = apiHandler(async () => {
@@ -123,6 +156,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
dosesPerUnit,
deliveryForm,
notificationsEnabled,
liveActivityEnabled,
criticalAlarmEnabled,
schedules,
startsOn,
endsOn,
@@ -184,6 +219,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
...(dosesPerUnit !== undefined && { dosesPerUnit }),
// v1.6.0 — route of administration; Prisma defaults to ORAL when omitted.
...(deliveryForm !== undefined && { deliveryForm }),
// v1.7.0 — iOS reminder flags; Prisma defaults both to false.
...(liveActivityEnabled !== undefined && { liveActivityEnabled }),
...(criticalAlarmEnabled !== undefined && { criticalAlarmEnabled }),
// v1.5 — wizard's reminders toggle now ships through the create
// payload (was orphaned in the initial diff). Prisma defaults to
// true when omitted, matching the legacy form's behaviour.
@@ -195,9 +233,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
schedules: {
create: schedules.map((s) => {
// Invariant 2 — default to FREQ=DAILY when nothing else is set.
// v1.7.0 — PRN schedules carry no cadence, so never default
// them to FREQ=DAILY (they would otherwise project + remind).
const hasLegacyDays = (s.daysOfWeek?.length ?? 0) > 0;
const isPrn = s.scheduleType === "PRN";
const defaultedRrule =
!oneShot &&
!isPrn &&
s.rrule === undefined &&
s.rollingIntervalDays === undefined &&
!hasLegacyDays
@@ -229,6 +271,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
...(s.rollingIntervalDays !== undefined && {
rollingIntervalDays: s.rollingIntervalDays,
}),
// v1.7.0 — schedule type + cyclic weeks, field-by-field.
...(s.scheduleType !== undefined && { scheduleType: s.scheduleType }),
...(s.cyclicOnWeeks !== undefined && {
cyclicOnWeeks: s.cyclicOnWeeks,
}),
...(s.cyclicOffWeeks !== undefined && {
cyclicOffWeeks: s.cyclicOffWeeks,
}),
};
}),
},
@@ -8,6 +8,7 @@ vi.mock("@/lib/db", () => ({
prisma: {
moodEntry: {
findUnique: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
@@ -61,6 +62,12 @@ beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
// v1.7.0 sync — PUT now looks the row up via `findFirst` with a
// `deletedAt: null` guard (refuses to resurrect-edit a tombstone).
vi.mocked(prisma.moodEntry.findFirst).mockResolvedValue({
id: "me1",
userId: "user-1",
} as never);
vi.mocked(prisma.moodEntry.findUnique).mockResolvedValue({
id: "me1",
userId: "user-1",
+31 -3
View File
@@ -36,7 +36,12 @@ export const GET = apiHandler(
const { id } = await params;
const entry = await prisma.moodEntry.findUnique({ where: { id } });
// v1.7.0 sync — a soft-deleted (tombstoned) row 404s on a direct GET,
// matching the list / analytics / rollup read invariant. `findFirst`
// (not `findUnique`) because `deletedAt` is not part of a unique index.
const entry = await prisma.moodEntry.findFirst({
where: { id, deletedAt: null },
});
if (!entry || entry.userId !== user.id) {
return apiError("Mood entry not found", 404);
@@ -57,7 +62,11 @@ export const PUT = apiHandler(
const { id } = await params;
const existing = await prisma.moodEntry.findUnique({ where: { id } });
// v1.7.0 sync — refuse to resurrect-edit a tombstoned row; the
// `deletedAt: null` filter makes a soft-deleted entry 404 on PUT.
const existing = await prisma.moodEntry.findFirst({
where: { id, deletedAt: null },
});
if (!existing || existing.userId !== user.id) {
return apiError("Mood entry not found", 404);
@@ -119,6 +128,11 @@ export const PUT = apiHandler(
updateData.note = data.note;
}
// v1.7.0 sync — mood is last-writer-wins by syncVersion; bump it on
// every server-side edit so the `/api/sync/changes` feed echoes a
// monotonic value and paired clients reconcile higher-wins.
updateData.syncVersion = { increment: 1 };
const entry = await prisma.moodEntry.update({
where: { id },
data: updateData,
@@ -179,7 +193,21 @@ export const DELETE = apiHandler(
return apiError("Mood entry not found", 404);
}
await prisma.moodEntry.delete({ where: { id } });
// v1.7.0 sync — soft-delete instead of a hard `delete`. Setting
// `deletedAt` (+ bumping `syncVersion`) leaves the row in place so the
// `/api/sync/changes` feed surfaces it as a tombstone (keyed on the
// server `id`) to paired clients that were offline at delete time.
// Every list / detail / analytics / rollup read filters
// `deletedAt: null`, so the row is invisible to normal reads from
// here on. A re-delete of an already-tombstoned row re-bumps
// `syncVersion` harmlessly (idempotent).
await prisma.moodEntry.update({
where: { id },
data: {
deletedAt: new Date(),
syncVersion: { increment: 1 },
},
});
await auditLog("moodEntry.delete", {
userId: user.id,
+2
View File
@@ -71,6 +71,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
const where = {
userId: user.id,
// v1.7.0 sync — hide soft-deleted (tombstoned) rows from the list.
deletedAt: null,
...(mood && { mood }),
...(from || to
? {
+2 -1
View File
@@ -124,7 +124,8 @@ async function buildMoodAnalyticsResponse(
// so the request still gets a correct response — the warm-up
// fired above will mint the rollups for the next request.
const moodEntries = await prisma.moodEntry.findMany({
where: { userId },
// v1.7.0 sync — exclude tombstoned rows from the analytics fallback.
where: { userId, deletedAt: null },
orderBy: { moodLoggedAt: "asc" },
select: { date: true, score: true, moodLoggedAt: true },
});
+401
View File
@@ -0,0 +1,401 @@
/**
* `GET /api/sync/changes` — multi-domain delta feed (v1.7.0).
*
* The incremental catch-up feed for paired clients. After the first-pair
* backfill (which uses the batch endpoints), the client drains this feed
* to pick up server-side changes — including deletions, which surface as
* tombstones because the user-facing DELETE routes on every served
* domain now soft-delete (set `deletedAt` + bump `syncVersion`) rather
* than hard-delete.
*
* Domains served: `measurements`, `mood`, `intakes`. The iOS consumer
* is measurements-only this cycle (iOS-coord
* `v1.7.0-ios-offline-sync-answers.md` §7.1); mood + intakes are
* forward-prep so the contract is complete + tested before the
* multi-domain consumer lands.
*
* Contract:
* - One opaque multi-domain keyset cursor wrapping a per-domain
* `(updatedAt, id)` watermark; the client treats it as fully opaque
* (echo, never parse — iOS-coord §7.6). `limit` default 200, hard
* cap 500, applied PER DOMAIN.
* - Each domain block carries `tombstones` (soft-deleted rows) AND
* `upserts` (live rows). The client MUST apply tombstones before
* upserts within each domain to avoid resurrecting a row whose
* delete and a later re-insert both fall in the page.
* - Tombstone identity per the iOS §7.3 table: measurements key on
* `externalId` (the cross-device key); mood + intakes key on the
* server `id` (the client mirrors those by server id).
* - `hasMore` is true when ANY served domain still has rows past its
* page; the next `cursor` advances every domain that returned rows.
* When `hasMore` is false the client is caught up as of `serverNow`.
* - `cursorExpired: true` when ANY domain watermark predates the
* tombstone-retention horizon — the client drops its cursor and does
* a clean initial sync (a deletion older than retention may have
* been pruned, so an incremental delta could silently miss it).
* - `syncVersion` is echoed per row so the client can keep its mirror's
* version monotonic.
*
* `apiHandler` + `requireAuth` (cookie OR Bearer; iOS uses Bearer).
* Read-only — no idempotency, no write side-effect (unlike the legacy
* `/api/sync/state` checkpoint bump). The cursor is owned by the client.
*/
import type { NextRequest } from "next/server";
import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { TOMBSTONE_RETENTION_DAYS } from "@/lib/auth/native-client";
import {
decodeCursor,
encodeCursor,
SYNC_DOMAINS,
type DomainWatermark,
type SyncCursor,
type SyncDomain,
} from "@/lib/sync/cursor";
const DEFAULT_LIMIT = 200;
const MAX_LIMIT = 500;
const DAY_MS = 86_400_000;
const querySchema = z.object({
cursor: z.string().min(1).max(2048).optional(),
limit: z.coerce.number().int().min(1).max(MAX_LIMIT).optional(),
});
interface MeasurementUpsert {
id: string;
externalId: string | null;
type: string;
value: number;
unit: string;
measuredAt: string;
source: string;
notes: string | null;
syncVersion: number;
updatedAt: string;
}
interface MeasurementTombstone {
id: string;
externalId: string | null;
syncVersion: number;
deletedAt: string;
updatedAt: string;
}
interface MoodUpsert {
id: string;
date: string;
mood: string;
score: number;
tags: string | null;
note: string | null;
moodLoggedAt: string;
source: string;
syncVersion: number;
updatedAt: string;
}
interface MoodTombstone {
id: string;
syncVersion: number;
deletedAt: string;
updatedAt: string;
}
interface IntakeUpsert {
id: string;
medicationId: string;
scheduledFor: string;
takenAt: string | null;
skipped: boolean;
source: string;
syncVersion: number;
updatedAt: string;
}
interface IntakeTombstone {
id: string;
syncVersion: number;
deletedAt: string;
updatedAt: string;
}
/**
* Build the `(updatedAt, id)` keyset predicate for a domain's watermark.
* Empty object (full scan) when the client has no watermark for it yet.
*/
function keysetFilter(wm: DomainWatermark | undefined) {
if (!wm) return {};
const at = new Date(wm.updatedAtMs);
return {
OR: [
{ updatedAt: { gt: at } },
{ AND: [{ updatedAt: at }, { id: { gt: wm.id } }] },
],
};
}
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
// Pull is cheap + idempotent; a generous per-user bucket caps a runaway
// drain loop without throttling normal foreground catch-up.
const rl = await checkRateLimit(`sync:changes:${user.id}`, 120, 60 * 1000);
if (!rl.allowed) {
return apiError("Too many sync requests. Please retry later.", 429);
}
const url = new URL(request.url);
const parsed = querySchema.safeParse({
cursor: url.searchParams.get("cursor") ?? undefined,
limit: url.searchParams.get("limit") ?? undefined,
});
if (!parsed.success) {
return apiError("Invalid sync query", 422);
}
const limit = parsed.data.limit ?? DEFAULT_LIMIT;
const serverNow = new Date();
// The retention horizon: tombstones older than this may have been
// pruned by the cleanup job, so a cursor that predates it can no longer
// be trusted to deliver every deletion incrementally.
const retentionHorizon = new Date(
serverNow.getTime() - TOMBSTONE_RETENTION_DAYS * DAY_MS,
);
// A garbage / unparseable / old-format cursor decodes to null → fresh
// initial sync (every domain scans from the start).
const cursor: SyncCursor = parsed.data.cursor
? (decodeCursor(parsed.data.cursor) ?? {})
: {};
// A stale cursor (ANY domain watermark older than retention) forces a
// clean re-init: a deletion older than retention may already be pruned.
const expired = SYNC_DOMAINS.some((domain) => {
const wm = cursor[domain];
return wm !== undefined && wm.updatedAtMs < retentionHorizon.getTime();
});
if (expired) {
annotate({
action: { name: "sync.changes.pull" },
meta: { cursor_expired: true, returned: 0 },
});
return apiSuccess({
serverNow: serverNow.toISOString(),
cursor: parsed.data.cursor ?? null,
hasMore: false,
cursorExpired: true,
changes: {
measurements: { upserts: [], tombstones: [] },
mood: { upserts: [], tombstones: [] },
intakes: { upserts: [], tombstones: [] },
},
});
}
// Per-domain keyset walks. Both live and soft-deleted rows are in the
// same scan — a soft-delete bumps `updatedAt`, so a tombstone is just a
// row whose `deletedAt` is non-null. Fetch limit+1 to detect `hasMore`
// without a count query.
const [measurementRows, moodRows, intakeRows] = await Promise.all([
prisma.measurement.findMany({
where: { userId: user.id, ...keysetFilter(cursor.measurements) },
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
take: limit + 1,
select: {
id: true,
externalId: true,
type: true,
value: true,
unit: true,
measuredAt: true,
source: true,
notes: true,
syncVersion: true,
deletedAt: true,
updatedAt: true,
},
}),
prisma.moodEntry.findMany({
where: { userId: user.id, ...keysetFilter(cursor.mood) },
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
take: limit + 1,
select: {
id: true,
date: true,
mood: true,
score: true,
tags: true,
note: true,
moodLoggedAt: true,
source: true,
syncVersion: true,
deletedAt: true,
updatedAt: true,
},
}),
prisma.medicationIntakeEvent.findMany({
where: { userId: user.id, ...keysetFilter(cursor.intakes) },
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
take: limit + 1,
select: {
id: true,
medicationId: true,
scheduledFor: true,
takenAt: true,
skipped: true,
source: true,
syncVersion: true,
deletedAt: true,
updatedAt: true,
},
}),
]);
const nextCursor: SyncCursor = { ...cursor };
let hasMore = false;
// ── measurements ──────────────────────────────────────────
const mHasMore = measurementRows.length > limit;
const mPage = mHasMore ? measurementRows.slice(0, limit) : measurementRows;
const measurementUpserts: MeasurementUpsert[] = [];
const measurementTombstones: MeasurementTombstone[] = [];
for (const row of mPage) {
if (row.deletedAt) {
measurementTombstones.push({
id: row.id,
externalId: row.externalId,
syncVersion: row.syncVersion,
deletedAt: row.deletedAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
});
} else {
measurementUpserts.push({
id: row.id,
externalId: row.externalId,
type: row.type,
value: row.value,
unit: row.unit,
measuredAt: row.measuredAt.toISOString(),
source: row.source,
notes: row.notes,
syncVersion: row.syncVersion,
updatedAt: row.updatedAt.toISOString(),
});
}
}
advanceCursor(nextCursor, "measurements", mPage);
hasMore = hasMore || mHasMore;
// ── mood ──────────────────────────────────────────────────
const moodHasMore = moodRows.length > limit;
const moodPage = moodHasMore ? moodRows.slice(0, limit) : moodRows;
const moodUpserts: MoodUpsert[] = [];
const moodTombstones: MoodTombstone[] = [];
for (const row of moodPage) {
if (row.deletedAt) {
moodTombstones.push({
id: row.id,
syncVersion: row.syncVersion,
deletedAt: row.deletedAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
});
} else {
moodUpserts.push({
id: row.id,
date: row.date,
mood: row.mood,
score: row.score,
tags: row.tags,
note: row.note,
moodLoggedAt: row.moodLoggedAt.toISOString(),
source: row.source,
syncVersion: row.syncVersion,
updatedAt: row.updatedAt.toISOString(),
});
}
}
advanceCursor(nextCursor, "mood", moodPage);
hasMore = hasMore || moodHasMore;
// ── intakes ───────────────────────────────────────────────
const intakeHasMore = intakeRows.length > limit;
const intakePage = intakeHasMore ? intakeRows.slice(0, limit) : intakeRows;
const intakeUpserts: IntakeUpsert[] = [];
const intakeTombstones: IntakeTombstone[] = [];
for (const row of intakePage) {
if (row.deletedAt) {
intakeTombstones.push({
id: row.id,
syncVersion: row.syncVersion,
deletedAt: row.deletedAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
});
} else {
intakeUpserts.push({
id: row.id,
medicationId: row.medicationId,
scheduledFor: row.scheduledFor.toISOString(),
takenAt: row.takenAt?.toISOString() ?? null,
skipped: row.skipped,
source: row.source,
syncVersion: row.syncVersion,
updatedAt: row.updatedAt.toISOString(),
});
}
}
advanceCursor(nextCursor, "intakes", intakePage);
hasMore = hasMore || intakeHasMore;
annotate({
action: { name: "sync.changes.pull" },
meta: {
measurement_upserts: measurementUpserts.length,
measurement_tombstones: measurementTombstones.length,
mood_upserts: moodUpserts.length,
mood_tombstones: moodTombstones.length,
intake_upserts: intakeUpserts.length,
intake_tombstones: intakeTombstones.length,
has_more: hasMore,
cursor_present: Boolean(parsed.data.cursor),
},
});
return apiSuccess({
serverNow: serverNow.toISOString(),
cursor: encodeCursor(nextCursor),
hasMore,
cursorExpired: false,
changes: {
measurements: {
upserts: measurementUpserts,
tombstones: measurementTombstones,
},
mood: { upserts: moodUpserts, tombstones: moodTombstones },
intakes: { upserts: intakeUpserts, tombstones: intakeTombstones },
},
});
});
/**
* Advance a domain's watermark to the last row of its page. A domain
* that returned no rows keeps its prior watermark untouched so a future
* pull does not re-scan from the start.
*/
function advanceCursor(
cursor: SyncCursor,
domain: SyncDomain,
page: Array<{ updatedAt: Date; id: string }>,
): void {
const last = page[page.length - 1];
if (last) {
cursor[domain] = { updatedAtMs: last.updatedAt.getTime(), id: last.id };
}
}
+76 -15
View File
@@ -14,13 +14,16 @@
* timezone,
* lastSyncedAt, — User.lastSyncedAt (ISO string or null)
* serverNow, — `new Date().toISOString()` for clock skew
* measurements: {
* lastUpdatedAt, — MAX(updated_at) for live rows
* liveCount, — COUNT(*) WHERE deleted_at IS NULL
* tombstonedCount, — COUNT(*) WHERE deleted_at IS NOT NULL
* }
* measurements: { lastUpdatedAt, liveCount, tombstonedCount },
* mood: { lastUpdatedAt, liveCount, tombstonedCount },
* intakes: { lastUpdatedAt, liveCount, tombstonedCount },
* sync: { incrementalWindowDays, tombstoneRetentionDays }
* }
*
* Per-domain block: `lastUpdatedAt` = MAX(updated_at) for live rows,
* `liveCount` = COUNT WHERE deleted_at IS NULL, `tombstonedCount` =
* COUNT WHERE deleted_at IS NOT NULL.
*
* No body. requireAuth() — cookie + Bearer both accepted (iOS uses
* Bearer per `05-auth-flows.md`).
*
@@ -33,24 +36,58 @@ import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import {
NATIVE_REFRESH_TOKEN_DAYS,
TOMBSTONE_RETENTION_DAYS,
} from "@/lib/auth/native-client";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const GET = apiHandler(async (_request: NextRequest) => {
const { user } = await requireAuth();
// Two aggregate reads + one user-side bump. The aggregate reads run
// against indexed columns (userId, deleted_at) so they stay
// O(rows-for-user) and not O(rows-across-tenants).
const [latest, liveCount, tombstonedCount, currentUser] = await Promise.all([
// Per-domain aggregate reads + one user-side bump. Each runs against
// indexed columns (userId, deleted_at / updated_at) so it stays
// O(rows-for-user) and not O(rows-across-tenants). v1.7.0 adds the
// mood + intake blocks now that both carry the sync columns.
const [
measurementLatest,
measurementLive,
measurementTomb,
moodLatest,
moodLive,
moodTomb,
intakeLatest,
intakeLive,
intakeTomb,
currentUser,
] = await Promise.all([
prisma.measurement.findFirst({
where: { userId: user.id, deletedAt: null },
orderBy: { updatedAt: "desc" },
select: { updatedAt: true },
}),
prisma.measurement.count({ where: { userId: user.id, deletedAt: null } }),
prisma.measurement.count({
where: { userId: user.id, deletedAt: { not: null } },
}),
prisma.moodEntry.findFirst({
where: { userId: user.id, deletedAt: null },
orderBy: { updatedAt: "desc" },
select: { updatedAt: true },
}),
prisma.moodEntry.count({ where: { userId: user.id, deletedAt: null } }),
prisma.moodEntry.count({
where: { userId: user.id, deletedAt: { not: null } },
}),
prisma.medicationIntakeEvent.findFirst({
where: { userId: user.id, deletedAt: null },
orderBy: { updatedAt: "desc" },
select: { updatedAt: true },
}),
prisma.medicationIntakeEvent.count({
where: { userId: user.id, deletedAt: null },
}),
prisma.measurement.count({
prisma.medicationIntakeEvent.count({
where: { userId: user.id, deletedAt: { not: null } },
}),
prisma.user.findUnique({
@@ -73,8 +110,10 @@ export const GET = apiHandler(async (_request: NextRequest) => {
annotate({
action: { name: "sync.state" },
meta: {
liveCount,
tombstonedCount,
liveCount: measurementLive,
tombstonedCount: measurementTomb,
moodLiveCount: moodLive,
intakeLiveCount: intakeLive,
lastSyncedAtBefore: previous?.toISOString() ?? null,
},
});
@@ -85,9 +124,31 @@ export const GET = apiHandler(async (_request: NextRequest) => {
lastSyncedAt: previous?.toISOString() ?? null,
serverNow: nextCheckpoint.toISOString(),
measurements: {
lastUpdatedAt: latest?.updatedAt?.toISOString() ?? null,
liveCount,
tombstonedCount,
lastUpdatedAt: measurementLatest?.updatedAt?.toISOString() ?? null,
liveCount: measurementLive,
tombstonedCount: measurementTomb,
},
// v1.7.0 — mood + intake summaries, now that both carry the sync
// columns. Same shape as `measurements` so the client reads them
// uniformly.
mood: {
lastUpdatedAt: moodLatest?.updatedAt?.toISOString() ?? null,
liveCount: moodLive,
tombstonedCount: moodTomb,
},
intakes: {
lastUpdatedAt: intakeLatest?.updatedAt?.toISOString() ?? null,
liveCount: intakeLive,
tombstonedCount: intakeTomb,
},
// v1.7.0 — the sync window iOS should derive its incremental-delta
// horizon from, rather than hardcoding a constant. `incrementalWindowDays`
// tracks the native refresh-token lifetime (a device offline longer
// re-pairs with a full backfill); `tombstoneRetentionDays` is the
// horizon past which `/api/sync/changes` returns `cursorExpired`.
sync: {
incrementalWindowDays: NATIVE_REFRESH_TOKEN_DAYS,
tombstoneRetentionDays: TOMBSTONE_RETENTION_DAYS,
},
});
});
+21
View File
@@ -17,6 +17,7 @@ import {
import { annotate } from "@/lib/logging/context";
import { applyProfileUpdate } from "@/lib/auth/profile-update";
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
export const dynamic = "force-dynamic";
@@ -36,9 +37,23 @@ export const GET = apiHandler(async () => {
locale: true,
timezone: true,
moodReminderEnabled: true,
fullName: true,
insurerName: true,
insuranceNumberEncrypted: true,
},
});
// v1.7.0 — decrypt the KVNR fail-soft so a key-rotation gap never 500s
// the profile fetch.
let insuranceNumber: string | null = null;
if (dbUser?.insuranceNumberEncrypted) {
try {
insuranceNumber = decrypt(dbUser.insuranceNumberEncrypted);
} catch {
insuranceNumber = null;
}
}
return apiSuccess({
username: dbUser?.username ?? user.username,
displayName: dbUser?.displayName ?? null,
@@ -49,6 +64,9 @@ export const GET = apiHandler(async () => {
locale: dbUser?.locale ?? null,
timezone: dbUser?.timezone ?? "Europe/Berlin",
moodReminderEnabled: dbUser?.moodReminderEnabled ?? false,
fullName: dbUser?.fullName ?? null,
insurerName: dbUser?.insurerName ?? null,
insuranceNumber,
});
});
@@ -75,5 +93,8 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
locale: result.user.locale,
timezone: result.user.timezone,
moodReminderEnabled: result.user.moodReminderEnabled,
fullName: result.user.fullName,
insurerName: result.user.insurerName,
hasInsuranceNumber: result.user.hasInsuranceNumber,
});
});
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { Wind } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/atemfrequenz`.
*
* RESPIRATORY_RATE sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
* carries no manual-entry CTA — the series arrives from Apple Health or
* Withings sync.
*/
export default function InsightsAtemfrequenzPage() {
return (
<HealthKitMetricPage
measurementType="RESPIRATORY_RATE"
insightMetric="RESPIRATORY_RATE"
chartKey="respiratoryRate"
i18nPrefix="insights.respiratoryRate"
color="#8be9fd"
unit="breaths/min"
yAxisUnit="breaths/min"
emptyStateIcon={<Wind className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any respiratory rate yet — what does this metric tell me about my health, and how do I improve it?"
/>
);
}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { Droplet } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/blutzucker`.
*
* BLOOD_GLUCOSE sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
* carries no manual-entry CTA — the series arrives from Apple Health or
* Withings sync.
*/
export default function InsightsBlutzuckerPage() {
return (
<HealthKitMetricPage
measurementType="BLOOD_GLUCOSE"
insightMetric="BLOOD_GLUCOSE"
chartKey="bloodGlucose"
i18nPrefix="insights.bloodGlucose"
color="#ff79c6"
unit="mg/dL"
yAxisUnit="mg/dL"
emptyStateIcon={<Droplet className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any blood glucose yet — what does this metric tell me about my health, and how do I improve it?"
/>
);
}
@@ -0,0 +1,30 @@
"use client";
import { Footprints } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/doppelstandphase`.
*
* WALKING_DOUBLE_SUPPORT sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
* carries no manual-entry CTA — the series arrives from Apple Health or
* Withings sync.
*/
export default function InsightsDoppelstandphasePage() {
return (
<HealthKitMetricPage
measurementType="WALKING_DOUBLE_SUPPORT"
insightMetric="WALKING_DOUBLE_SUPPORT"
chartKey="walkingDoubleSupport"
i18nPrefix="insights.walkingDoubleSupport"
color="#50fa7b"
unit="%"
yAxisUnit="%"
emptyStateIcon={<Footprints className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any double support time yet — what does this metric tell me about my health, and how do I improve it?"
/>
);
}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { Scale } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/fettfreie-masse`.
*
* FAT_FREE_MASS sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
* carries no manual-entry CTA — the series arrives from Apple Health or
* Withings sync.
*/
export default function InsightsFettfreieMassePage() {
return (
<HealthKitMetricPage
measurementType="FAT_FREE_MASS"
insightMetric="FAT_FREE_MASS"
chartKey="fatFreeMass"
i18nPrefix="insights.fatFreeMass"
color="#bd93f9"
unit="kg"
yAxisUnit="kg"
emptyStateIcon={<Scale className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any fat-free mass yet — what does this metric tell me about my health, and how do I improve it?"
/>
);
}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { Droplets } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/fettmasse`.
*
* FAT_MASS sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
* carries no manual-entry CTA — the series arrives from Apple Health or
* Withings sync.
*/
export default function InsightsFettmassePage() {
return (
<HealthKitMetricPage
measurementType="FAT_MASS"
insightMetric="FAT_MASS"
chartKey="fatMass"
i18nPrefix="insights.fatMass"
color="#ffb86c"
unit="kg"
yAxisUnit="kg"
emptyStateIcon={<Droplets className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any fat mass yet — what does this metric tell me about my health, and how do I improve it?"
/>
);
}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { Footprints } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/gangasymmetrie`.
*
* WALKING_ASYMMETRY sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
* carries no manual-entry CTA — the series arrives from Apple Health or
* Withings sync.
*/
export default function InsightsGangasymmetriePage() {
return (
<HealthKitMetricPage
measurementType="WALKING_ASYMMETRY"
insightMetric="WALKING_ASYMMETRY"
chartKey="walkingAsymmetry"
i18nPrefix="insights.walkingAsymmetry"
color="#50fa7b"
unit="%"
yAxisUnit="%"
emptyStateIcon={<Footprints className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any walking asymmetry yet — what does this metric tell me about my health, and how do I improve it?"
/>
);
}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { Gauge } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/gangstabilitaet`.
*
* WALKING_STEADINESS sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
* carries no manual-entry CTA — the series arrives from Apple Health or
* Withings sync.
*/
export default function InsightsGangstabilitaetPage() {
return (
<HealthKitMetricPage
measurementType="WALKING_STEADINESS"
insightMetric="WALKING_STEADINESS"
chartKey="walkingSteadiness"
i18nPrefix="insights.walkingSteadiness"
color="#50fa7b"
unit="%"
yAxisUnit="%"
emptyStateIcon={<Gauge className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any walking steadiness yet — what does this metric tell me about my health, and how do I improve it?"
/>
);
}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { HeartPulse } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/gefaessalter`.
*
* VASCULAR_AGE sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
* carries no manual-entry CTA — the series arrives from Apple Health or
* Withings sync.
*/
export default function InsightsGefaessalterPage() {
return (
<HealthKitMetricPage
measurementType="VASCULAR_AGE"
insightMetric="VASCULAR_AGE"
chartKey="vascularAge"
i18nPrefix="insights.vascularAge"
color="#ff79c6"
unit="years"
yAxisUnit="years"
emptyStateIcon={<HeartPulse className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any vascular age yet — what does this metric tell me about my health, and how do I improve it?"
/>
);
}
@@ -0,0 +1,31 @@
"use client";
import { Gauge } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/gehgeschwindigkeit`.
*
* WALKING_SPEED sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
* carries no manual-entry CTA — the series arrives from Apple Health or
* Withings sync.
*/
export default function InsightsGehgeschwindigkeitPage() {
return (
<HealthKitMetricPage
measurementType="WALKING_SPEED"
insightMetric="WALKING_SPEED"
chartKey="walkingSpeed"
i18nPrefix="insights.walkingSpeed"
color="#50fa7b"
unit="km/h"
yAxisUnit="km/h"
valueScale={3.6}
emptyStateIcon={<Gauge className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any walking speed yet — what does this metric tell me about my health, and how do I improve it?"
/>
);
}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { HeartPulse } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/gehpuls`.
*
* WALKING_HEART_RATE_AVERAGE sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
* carries no manual-entry CTA — the series arrives from Apple Health or
* Withings sync.
*/
export default function InsightsGehpulsPage() {
return (
<HealthKitMetricPage
measurementType="WALKING_HEART_RATE_AVERAGE"
insightMetric="WALKING_HEART_RATE_AVERAGE"
chartKey="walkingHeartRateAverage"
i18nPrefix="insights.walkingHeartRateAverage"
color="#ff5555"
unit="bpm"
yAxisUnit="bpm"
emptyStateIcon={<HeartPulse className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any walking heart rate average yet — what does this metric tell me about my health, and how do I improve it?"
/>
);
}
+32
View File
@@ -0,0 +1,32 @@
"use client";
import { Footprints } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/gehstrecke`.
*
* WALKING_RUNNING_DISTANCE sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
* carries no manual-entry CTA — the series arrives from Apple Health or
* Withings sync.
*/
export default function InsightsGehstreckePage() {
return (
<HealthKitMetricPage
measurementType="WALKING_RUNNING_DISTANCE"
insightMetric="WALKING_RUNNING_DISTANCE"
chartKey="walkingRunningDistance"
i18nPrefix="insights.walkingRunningDistance"
color="#50fa7b"
unit="km"
yAxisUnit="km"
// Stored in metres; display daily totals in km (1 m = 0.001 km).
valueScale={0.001}
emptyStateIcon={<Footprints className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any walking and running distance yet — what does this metric tell me about my health, and how do I improve it?"
/>
);
}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { Thermometer } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/hauttemperatur`.
*
* SKIN_TEMPERATURE sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
* carries no manual-entry CTA — the series arrives from Apple Health or
* Withings sync.
*/
export default function InsightsHauttemperaturPage() {
return (
<HealthKitMetricPage
measurementType="SKIN_TEMPERATURE"
insightMetric="SKIN_TEMPERATURE"
chartKey="skinTemperature"
i18nPrefix="insights.skinTemperature"
color="#ffb86c"
unit="°C"
yAxisUnit="°C"
emptyStateIcon={<Thermometer className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any skin temperature yet — what does this metric tell me about my health, and how do I improve it?"
/>
);
}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { Bone } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/knochenmasse`.
*
* BONE_MASS sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
* carries no manual-entry CTA — the series arrives from Apple Health or
* Withings sync.
*/
export default function InsightsKnochenmassePage() {
return (
<HealthKitMetricPage
measurementType="BONE_MASS"
insightMetric="BONE_MASS"
chartKey="boneMass"
i18nPrefix="insights.boneMass"
color="#ffb86c"
unit="kg"
yAxisUnit="kg"
emptyStateIcon={<Bone className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any bone mass yet — what does this metric tell me about my health, and how do I improve it?"
/>
);
}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { Droplet } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/koerperwasser`.
*
* TOTAL_BODY_WATER sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
* carries no manual-entry CTA — the series arrives from Apple Health or
* Withings sync.
*/
export default function InsightsKoerperwasserPage() {
return (
<HealthKitMetricPage
measurementType="TOTAL_BODY_WATER"
insightMetric="TOTAL_BODY_WATER"
chartKey="totalBodyWater"
i18nPrefix="insights.totalBodyWater"
color="#8be9fd"
unit="kg"
yAxisUnit="kg"
emptyStateIcon={<Droplet className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any total body water yet — what does this metric tell me about my health, and how do I improve it?"
/>
);
}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { Headphones } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/kopfhoererpegel`.
*
* AUDIO_EXPOSURE_HEADPHONE sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
* carries no manual-entry CTA — the series arrives from Apple Health or
* Withings sync.
*/
export default function InsightsKopfhoererpegelPage() {
return (
<HealthKitMetricPage
measurementType="AUDIO_EXPOSURE_HEADPHONE"
insightMetric="AUDIO_EXPOSURE_HEADPHONE"
chartKey="audioExposureHeadphone"
i18nPrefix="insights.audioExposureHeadphone"
color="#ff79c6"
unit="dBA"
yAxisUnit="dBA"
emptyStateIcon={<Headphones className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any headphone audio exposure yet — what does this metric tell me about my health, and how do I improve it?"
/>
);
}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { Volume2 } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/laermbelastung`.
*
* AUDIO_EXPOSURE_ENV sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
* carries no manual-entry CTA — the series arrives from Apple Health or
* Withings sync.
*/
export default function InsightsLaermbelastungPage() {
return (
<HealthKitMetricPage
measurementType="AUDIO_EXPOSURE_ENV"
insightMetric="AUDIO_EXPOSURE_ENV"
chartKey="audioExposureEnv"
i18nPrefix="insights.audioExposureEnv"
color="#ff79c6"
unit="dBA"
yAxisUnit="dBA"
emptyStateIcon={<Volume2 className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any environmental sound exposure yet — what does this metric tell me about my health, and how do I improve it?"
/>
);
}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { Volume2 } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/laermereignisse`.
*
* AUDIO_EXPOSURE_EVENT sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
* carries no manual-entry CTA — the series arrives from Apple Health or
* Withings sync.
*/
export default function InsightsLaermereignissePage() {
return (
<HealthKitMetricPage
measurementType="AUDIO_EXPOSURE_EVENT"
insightMetric="AUDIO_EXPOSURE_EVENT"
chartKey="audioExposureEvent"
i18nPrefix="insights.audioExposureEvent"
color="#ff79c6"
unit=""
yAxisUnit=""
emptyStateIcon={<Volume2 className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any loud-environment events yet — what does this metric tell me about my health, and how do I improve it?"
/>
);
}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { Scale } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/magermasse`.
*
* LEAN_BODY_MASS sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
* carries no manual-entry CTA — the series arrives from Apple Health or
* Withings sync.
*/
export default function InsightsMagermassePage() {
return (
<HealthKitMetricPage
measurementType="LEAN_BODY_MASS"
insightMetric="LEAN_BODY_MASS"
chartKey="leanBodyMass"
i18nPrefix="insights.leanBodyMass"
color="#bd93f9"
unit="kg"
yAxisUnit="kg"
emptyStateIcon={<Scale className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any lean body mass yet — what does this metric tell me about my health, and how do I improve it?"
/>
);
}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { Dumbbell } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/muskelmasse`.
*
* MUSCLE_MASS sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
* carries no manual-entry CTA — the series arrives from Apple Health or
* Withings sync.
*/
export default function InsightsMuskelmassePage() {
return (
<HealthKitMetricPage
measurementType="MUSCLE_MASS"
insightMetric="MUSCLE_MASS"
chartKey="muscleMass"
i18nPrefix="insights.muscleMass"
color="#bd93f9"
unit="kg"
yAxisUnit="kg"
emptyStateIcon={<Dumbbell className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any muscle mass yet — what does this metric tell me about my health, and how do I improve it?"
/>
);
}
@@ -0,0 +1,30 @@
"use client";
import { Activity } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/pulswellengeschwindigkeit`.
*
* PULSE_WAVE_VELOCITY sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
* carries no manual-entry CTA — the series arrives from Apple Health or
* Withings sync.
*/
export default function InsightsPulswellengeschwindigkeitPage() {
return (
<HealthKitMetricPage
measurementType="PULSE_WAVE_VELOCITY"
insightMetric="PULSE_WAVE_VELOCITY"
chartKey="pulseWaveVelocity"
i18nPrefix="insights.pulseWaveVelocity"
color="#ff5555"
unit="m/s"
yAxisUnit="m/s"
emptyStateIcon={<Activity className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any pulse wave velocity yet — what does this metric tell me about my health, and how do I improve it?"
/>
);
}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { Footprints } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/schrittlaenge`.
*
* WALKING_STEP_LENGTH sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
* carries no manual-entry CTA — the series arrives from Apple Health or
* Withings sync.
*/
export default function InsightsSchrittlaengePage() {
return (
<HealthKitMetricPage
measurementType="WALKING_STEP_LENGTH"
insightMetric="WALKING_STEP_LENGTH"
chartKey="walkingStepLength"
i18nPrefix="insights.walkingStepLength"
color="#50fa7b"
unit="m"
yAxisUnit="m"
emptyStateIcon={<Footprints className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any walking step length yet — what does this metric tell me about my health, and how do I improve it?"
/>
);
}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { TrendingUp } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/stockwerke`.
*
* FLIGHTS_CLIMBED sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
* carries no manual-entry CTA — the series arrives from Apple Health or
* Withings sync.
*/
export default function InsightsStockwerkePage() {
return (
<HealthKitMetricPage
measurementType="FLIGHTS_CLIMBED"
insightMetric="FLIGHTS_CLIMBED"
chartKey="flightsClimbed"
i18nPrefix="insights.flightsClimbed"
color="#50fa7b"
unit="flights"
yAxisUnit="flights"
emptyStateIcon={<TrendingUp className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any flights climbed yet — what does this metric tell me about my health, and how do I improve it?"
/>
);
}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { Sun } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/tageslicht`.
*
* TIME_IN_DAYLIGHT sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
* carries no manual-entry CTA — the series arrives from Apple Health or
* Withings sync.
*/
export default function InsightsTageslichtPage() {
return (
<HealthKitMetricPage
measurementType="TIME_IN_DAYLIGHT"
insightMetric="TIME_IN_DAYLIGHT"
chartKey="timeInDaylight"
i18nPrefix="insights.timeInDaylight"
color="#50fa7b"
unit="min"
yAxisUnit="min"
emptyStateIcon={<Sun className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any time spent in daylight yet — what does this metric tell me about my health, and how do I improve it?"
/>
);
}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { Droplets } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
/**
* v1.7.0 — `/insights/viszeralfett`.
*
* VISCERAL_FAT sub-page. Reuses the generic HealthKitMetricPage scaffold;
* the chart daily-aggregates through the rollup read path. Empty-state
* carries no manual-entry CTA — the series arrives from Apple Health or
* Withings sync.
*/
export default function InsightsViszeralfettPage() {
return (
<HealthKitMetricPage
measurementType="VISCERAL_FAT"
insightMetric="VISCERAL_FAT"
chartKey="visceralFat"
i18nPrefix="insights.visceralFat"
color="#ffb86c"
unit=""
yAxisUnit=""
emptyStateIcon={<Droplets className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any visceral fat yet — what does this metric tell me about my health, and how do I improve it?"
/>
);
}
+84 -67
View File
@@ -1,17 +1,37 @@
"use client";
import { useEffect, use } from "react";
/**
* v1.7.0 — full intake-history view.
*
* Reached directly from the detail-page header's History button. This
* surface is intake-history ONLY: the estimated drug-level curve, the
* side-effect logbook, the schedule ("Rhythmus") editor and the
* titration ladder no longer dominate here. The only editable units
* are individual intakes (edit / delete / bulk-delete via the shared
* `<IntakeHistoryEditable>`); CSV import is present but de-emphasised
* as a ghost action in the header.
*
* Sort defaults to `scheduledFor desc` so the order reads
* today → yesterday → … and skipped rows (`takenAt: null`) never float
* to the top (O-1).
*
* The estimated active-ingredient curve stays available as a
* default-CLOSED disclosure at the bottom for GLP-1 medications (O-2);
* it is genuine history context but opt-in, not the default read.
*/
import { useEffect, useState, use } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { useRouter } from "next/navigation";
import { ArrowLeft, Loader2, Upload } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { DrugLevelChart } from "@/components/medications/DrugLevelChart";
import { SideEffectsSection } from "@/components/medications/SideEffectsSection";
import { SchedulingSection } from "@/components/medications/SchedulingSection";
import { TitrationSection } from "@/components/medications/TitrationSection";
import { IntakeHistoryListV2 } from "@/components/medications/intake-history-list-v2";
import { ArrowLeft, Loader2 } from "lucide-react";
import Link from "next/link";
import { IntakeHistoryEditable } from "@/components/medications/intake-history-editable";
import { IntakeImportDialog } from "@/components/medications/intake-import-dialog";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -25,6 +45,8 @@ export default function IntakeHistoryPage({
const router = useRouter();
const { t } = useTranslations();
const [importOpen, setImportOpen] = useState(false);
useEffect(() => {
if (!authLoading && !isAuthenticated) {
router.push("/auth/login");
@@ -56,93 +78,88 @@ export default function IntakeHistoryPage({
);
}
// D-H8 — bump the sibling-section stride from `space-y-4` (16 px)
// to `space-y-6` (24 px) so the medication-detail page matches the
// `/insights/*` sub-page stride. The earlier 16 px gap rode tight
// against each section's 1 px border (~14 px optical gap) and read
// dense after the heading collapse landed in the canonical
// `<MedicationDetailSection>`.
const isGlp1 = medication?.treatmentClass === "GLP1";
return (
<div className="space-y-6">
{/* Back to the medication detail page — history is a drill-down
OF the medication, not a sibling of the list. */}
<Button
variant="ghost"
size="sm"
className="text-muted-foreground -ml-2 gap-1"
asChild
>
<Link href="/medications">
<ArrowLeft className="h-4 w-4" />
{t("medications.back")}
<Link href={`/medications/${id}`}>
<ArrowLeft aria-hidden="true" className="h-4 w-4" />
{t("medications.detail.history.back")}
</Link>
</Button>
{medication && (
<div className="flex items-center justify-between">
<div>
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<h1 className="text-2xl font-bold tracking-tight">
{medication.name}
</h1>
<p className="text-muted-foreground hidden text-sm sm:block">
{medication.dose}
<p className="text-muted-foreground text-sm">{medication.dose}</p>
<p className="text-muted-foreground text-xs">
{t("medications.detail.history.subtitle")}
</p>
</div>
{/* De-emphasised import — ghost / muted, not a prominent CTA. */}
<Button
variant="ghost"
size="sm"
onClick={() => setImportOpen(true)}
className="text-muted-foreground min-h-11 sm:min-h-9"
data-slot="history-import-trigger"
>
<Upload aria-hidden="true" className="h-4 w-4" />
{t("medications.detail.intake.importButton")}
</Button>
</div>
)}
{/* v1.4.25 W19c-Frontend — Research-mode-gated drug-level chart.
Renders only for GLP-1 medications; the component itself
gates further on Research Mode + version-aligned
acknowledgment, so this page can mount it unconditionally
for any GLP-1 row and trust the chart's internal logic. */}
{medication?.treatmentClass === "GLP1" && (
<DrugLevelChart
medication={{
id: medication.id,
name: medication.name,
dose: medication.dose,
}}
/>
)}
{/* v1.4.25 W19d — GLP-1 side-effect logbook. Sits between the
drug-level chart and the intake history so the user lands on
the cycle context (where am I in the curve), then on the
symptom record, then on the dose-by-dose timeline. Mounted
only for GLP-1 medications; future waves (W19e reminders,
W19f titration ladder) hang off this same surface. */}
{medication?.treatmentClass === "GLP1" && (
<SideEffectsSection medicationId={id} />
)}
{/* v1.4.25 W19e — GLP-1 cadence visualisation + compliance chips.
Sits between the side-effect logbook and the intake history so
the user lands on cycle context (drug-level), then symptom
record, then schedule cadence + adherence, then the
dose-by-dose timeline. */}
{medication?.treatmentClass === "GLP1" && (
<SchedulingSection
{medication && (
<IntakeHistoryEditable
medicationId={id}
reminderEnabled={medication.notificationsEnabled ?? true}
pageSize={25}
defaultSortBy="scheduledFor"
/>
)}
{/* v1.4.25 W19f — GLP-1 titration-ladder display. Read-only EMA
reference visual showing the standard dose-escalation schedule
with the user's current step highlighted. Sits between
SchedulingSection and the bottom of the page; v1.4.28 retired
the IntakeHistoryList block that used to anchor the per-dose
timeline below this section. */}
{medication?.treatmentClass === "GLP1" && (
<TitrationSection medicationId={id} />
{/* O-2 — estimated active-ingredient curve + titration ladder as a
default-CLOSED disclosure for GLP-1 medications. Genuine
context, but opt-in so the intake table stays the dominant
surface. The chart itself gates further on Research Mode. */}
{medication && isGlp1 && (
<details
className="border-border/60 rounded-md border"
data-slot="history-drug-level-disclosure"
>
<summary className="text-foreground flex min-h-11 cursor-pointer items-center px-3 text-sm font-medium select-none sm:min-h-9">
{t("medications.detail.history.drugLevelDisclosure")}
</summary>
<div className="border-border/60 space-y-6 border-t px-3 py-3">
<DrugLevelChart
medication={{
id: medication.id,
name: medication.name,
dose: medication.dose,
}}
/>
<TitrationSection medicationId={id} />
</div>
</details>
)}
{/* v1.4.36 W4a — Lite intake-history table mounted for ALL
medication kinds. v1.4.28 dropped the v1 component on the
assumption it had no other consumer; the standalone history
page lost its dose timeline as a side-effect. The v2 is
read-only (no inline CRUD) — edit/delete still flow through
the regular intake routes. */}
{medication && <IntakeHistoryListV2 medicationId={id} />}
{importOpen && (
<IntakeImportDialog
medicationId={id}
onClose={() => setImportOpen(false)}
/>
)}
</div>
);
}
+7 -5
View File
@@ -236,8 +236,7 @@ export default function MedicationDetailPage({
const oneShot = medication.oneShot === true;
const intakeCount = intakeList?.meta.total ?? 0;
const primaryGrace =
medication.schedules[0]?.reminderGraceMinutes ?? null;
const primaryGrace = medication.schedules[0]?.reminderGraceMinutes ?? null;
// `wizardPayload` is non-null here — the early returns above bail
// before this point whenever `medication` is undefined.
const payload = wizardPayload as MedicationPayload;
@@ -263,15 +262,17 @@ export default function MedicationDetailPage({
</Link>
</Button>
{/* G-1 §3.2 — Header band. The Bearbeiten dropdown owns the
wizard ("Plan bearbeiten") + the advanced sheet ("Erweiterte
Einstellungen"); the page owns both open-states. */}
{/* v1.7.0 — Header band. Three buttons: Edit opens the wizard,
History routes directly to the full intake-history view, and
Advanced opens the settings sheet. The page owns the wizard +
advanced open-states; History is a plain navigation. */}
<MedicationDetailHeader
name={medication.name}
dose={medication.dose}
active={medication.active}
endsOn={medication.endsOn}
onEditPlan={() => setWizardOpen(true)}
onOpenHistory={() => router.push(`/medications/${id}/history`)}
onOpenAdvanced={() => setAdvancedOpen(true)}
/>
@@ -344,6 +345,7 @@ export default function MedicationDetailPage({
reminderGraceMinutes={primaryGrace}
intakeCount={intakeCount}
onRequestPhaseSheet={openPhaseSheet}
onOpenImport={() => setImportOpen(true)}
/>
{/* G-1 §5 — phase sheet mounted as a sibling of the advanced
+117 -17
View File
@@ -22,6 +22,7 @@ import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
import { cn } from "@/lib/utils";
import {
resolveDashboardLayout,
DASHBOARD_WIDGET_IDS,
type DashboardLayout,
} from "@/lib/dashboard-layout";
import type { DashboardAnalyticsData as AnalyticsData } from "@/types/analytics";
@@ -75,6 +76,8 @@ const MedicationComplianceChart = dynamic(
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
import { useDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
import type { DataSummary } from "@/lib/analytics/trends";
import { mergeSlimAndThickAnalytics } from "@/lib/analytics/merge-slim-thick";
import { getBpTargets } from "@/lib/analytics/bp-targets";
@@ -113,6 +116,34 @@ function getHourForTimeZone(timeZone?: string): number {
}
}
/**
* v1.7.0 — first-paint gate for the dashboard tile strip.
*
* `primaryLoading` must come from whichever query actually drives the
* tiles: the snapshot cell under `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=true`,
* the slim analytics cell otherwise. A disabled TanStack query reports
* `isLoading: false` (idle fetch status), so keying off the wrong
* source flashes the empty state for the whole fetch. Pure + exported
* so the gate has direct unit coverage without mounting the page.
*/
export function resolveDashboardFirstPaintGate(input: {
trendCardCount: number;
chartCount: number;
configuredTileCount: number;
primaryLoading: boolean;
}): { showTileStripSkeleton: boolean; showEmptyState: boolean } {
const showTileStripSkeleton =
input.trendCardCount === 0 &&
input.primaryLoading &&
input.configuredTileCount > 0;
const showEmptyState =
input.trendCardCount === 0 &&
input.chartCount === 0 &&
!showTileStripSkeleton &&
!input.primaryLoading;
return { showTileStripSkeleton, showEmptyState };
}
function getRangeColorClass(
value: number | null | undefined,
config: RangeDisplayConfig,
@@ -214,9 +245,46 @@ export default function DashboardPage() {
// Both queries share `caches.analytics` server-side so warm hits
// stay free, and TanStack's parallel-mounting keeps the network fan-
// out flat.
const analyticsSlimQuery = useAnalyticsQuery({ slice: "summaries" });
const analyticsThickQuery = useAnalyticsQuery();
// v1.7.0 W6 — unified first-paint snapshot (reversible rollout flag).
// When `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=true`, every tile hydrates from
// ONE un-gated `/api/dashboard/snapshot` cell so the whole strip
// shares one completion moment and the `/api/auth/me` round-trip
// leaves the cold critical path. Flag OFF keeps the legacy four
// independent cells (today's behaviour, byte-identical).
const snapshotEnabled = isDashboardSnapshotEnabled();
const snapshotQuery = useDashboardSnapshot(snapshotEnabled);
const analyticsSlimQuery = useAnalyticsQuery({
slice: "summaries",
enabled: !snapshotEnabled && isAuthenticated,
});
const analyticsThickQuery = useAnalyticsQuery({
enabled: !snapshotEnabled && isAuthenticated,
});
const data = useMemo<AnalyticsData | undefined>(() => {
// v1.7.0 W6 — snapshot path: assemble the same `AnalyticsData`
// shape from the single snapshot cell so every downstream tile
// reads unchanged. `extras` is null on a rollup-coverage miss
// (two-phase contract) → the BD-Zielbereich + glucose fields stay
// undefined and those tiles render their per-tile shimmer while the
// rest of the strip paints.
if (snapshotEnabled) {
const snap = snapshotQuery.data;
if (!snap) return undefined;
return {
summaries: snap.tiles.summaries,
lastSeenByType: snap.tiles.lastSeenByType,
bpInTargetPct: snap.extras?.bpInTargetPct ?? null,
bpInTargetPct7d: snap.extras?.bpInTargetPct7d ?? null,
bpInTargetPct30d: snap.extras?.bpInTargetPct30d ?? null,
bpInTargetPctAllTime: snap.extras?.bpInTargetPctAllTime ?? null,
bpInTargetPctPriorMonth: snap.extras?.bpInTargetPctPriorMonth ?? null,
bpInTargetPctPriorYear: snap.extras?.bpInTargetPctPriorYear ?? null,
glucoseByContext: snap.extras?.glucoseByContext as
| Record<string, DataSummary>
| undefined,
};
}
// v1.4.39.3 — the merge moved to `mergeSlimAndThickAnalytics` so
// the empty-slim-vs-populated-thick edge has direct unit
// coverage. Pre-fix the inline `slim?.summaries ?? thick?.summaries`
@@ -244,9 +312,14 @@ export default function DashboardPage() {
| Record<string, DataSummary>
| undefined,
};
}, [analyticsSlimQuery.data, analyticsThickQuery.data]);
}, [
snapshotEnabled,
snapshotQuery.data,
analyticsSlimQuery.data,
analyticsThickQuery.data,
]);
const { data: layoutData } = useQuery({
const { data: layoutDataLegacy } = useQuery({
queryKey: queryKeys.dashboardWidgets(),
queryFn: async () => {
const res = await fetch("/api/dashboard/widgets");
@@ -254,11 +327,14 @@ export default function DashboardPage() {
const json = await res.json();
return json.data as DashboardLayout;
},
enabled: isAuthenticated,
enabled: !snapshotEnabled && isAuthenticated,
...DASHBOARD_QUERY_OPTS,
});
const layoutData = snapshotEnabled
? snapshotQuery.data?.layout
: layoutDataLegacy;
const { data: moodData } = useQuery({
const { data: moodDataLegacy } = useQuery({
queryKey: queryKeys.moodAnalytics(),
queryFn: async () => {
const res = await fetch("/api/mood/analytics");
@@ -269,9 +345,18 @@ export default function DashboardPage() {
summary: DataSummary;
};
},
enabled: isAuthenticated,
enabled: !snapshotEnabled && isAuthenticated,
...DASHBOARD_QUERY_OPTS,
});
const moodData = snapshotEnabled
? snapshotQuery.data
? {
entries: snapshotQuery.data.tiles.mood.entries,
summary: (snapshotQuery.data.tiles.mood.summary ??
undefined) as DataSummary,
}
: undefined
: moodDataLegacy;
// v1.4.27 B1 — the dashboard's `<InsightsCardPreview>` retired (it
// duplicated the much-richer `/insights` advisor surface). The advisor
@@ -1318,13 +1403,32 @@ export default function DashboardPage() {
// configured tile count so the strip's footprint is reserved
// during the slow window. The skeleton swaps in for the real
// strip the moment `analyticsSlimQuery.isLoading` flips false.
// v1.7.0 — count only WEB-known tiles. The stored layout now
// round-trips the 11 iOS-only ids (so the native client can drop
// its merge workarounds), but the web dashboard has no tile
// component for them; including them here would over-reserve the
// skeleton silhouette by rows that never paint.
const webWidgetIds = new Set<string>(DASHBOARD_WIDGET_IDS);
const configuredTileCount = layout.widgets.filter(
(w) => w.tileVisible ?? w.visible,
(w) => webWidgetIds.has(w.id) && (w.tileVisible ?? w.visible),
).length;
const showTileStripSkeleton =
trendCards.length === 0 &&
analyticsSlimQuery.isLoading &&
configuredTileCount > 0;
// v1.7.0 — the primary data source differs by flag. In snapshot
// mode `analyticsSlimQuery` is `enabled: false`, and a disabled
// TanStack query reports `fetchStatus: "idle"` → `isLoading` is
// always `false`. Gating the skeleton on the slim query then
// never fires under snapshot mode, so the empty-state branch
// below flashes for the whole snapshot fetch. Key the loading
// flag off whichever query is actually driving the tiles.
const primaryLoading = snapshotEnabled
? snapshotQuery.isLoading
: analyticsSlimQuery.isLoading;
const { showTileStripSkeleton, showEmptyState } =
resolveDashboardFirstPaintGate({
trendCardCount: trendCards.length,
chartCount: charts.length,
configuredTileCount,
primaryLoading,
});
// v1.4.15 phase-C5: dashboard fully-empty state. When no tile
// and no chart has data the dashboard would otherwise paint a
@@ -1340,11 +1444,7 @@ export default function DashboardPage() {
// v1.4.43 W11 — the empty-state only fires once the slim slice
// resolves with no tiles to show. While slim is in flight the
// skeleton strip below carries the layout footprint.
if (
trendCards.length === 0 &&
charts.length === 0 &&
!showTileStripSkeleton
) {
if (showEmptyState) {
return (
<EmptyState
icon={<Activity className="size-6" />}
Loaded 100 of 237 files, more files were not shown because too many files have changed in this diff. Show more