diff --git a/CHANGELOG.md b/CHANGELOG.md index 72e361355..9c8f9f4d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index f1a550529..5ea334b1e 100644 --- a/README.md +++ b/README.md @@ -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 ``, ``, ``, and `` 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). diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 86bb5ba01..6151d9be6 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.6.0 + version: 1.7.0 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -31,10 +31,160 @@ tags: - name: Medications - name: Mood - name: Insights + - name: Dashboard - name: Notifications - name: Devices + - name: Export + - name: Sync - name: Admin paths: + /api/export/health-record: + post: + tags: + - Export + summary: Generate a health-record export (PDF / FHIR / package) + description: "v1.7.0 flagship export. Returns the doctor-handover artefact in the requested `format`: `pdf` → + application/pdf, `fhir` → application/fhir+json (HL7 FHIR R4 document Bundle), `package` → application/zip (PDF + + FHIR + README). Auth via cookie or Bearer; shared `export:` rate bucket (10/h). Strict validation: + unknown keys 422." + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + format: + type: string + enum: + - pdf + - fhir + - package + range: + type: object + properties: + startDate: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + endDate: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + days: + type: integer + minimum: 1 + maximum: 365 + additionalProperties: false + sections: + type: object + properties: + vitals: + type: object + properties: + weight: + type: boolean + bp: + type: boolean + pulse: + type: boolean + oxygenSaturation: + type: boolean + bodyFat: + type: boolean + bodyComposition: + type: boolean + cardioFitness: + type: object + properties: + restingHeartRate: + type: boolean + hrv: + type: boolean + vo2max: + type: boolean + activity: + type: object + properties: + steps: + type: boolean + distance: + type: boolean + energy: + type: boolean + sleep: + type: boolean + glucose: + type: boolean + medications: + type: object + properties: + list: + type: boolean + compliance: + type: boolean + glp1: + type: boolean + sideEffects: + type: boolean + mood: + type: boolean + bmi: + type: boolean + locale: + type: string + enum: + - de + - en + - fr + - es + - it + - pl + practiceName: + type: string + maxLength: 120 + includeCharts: + type: boolean + includeAiSummary: + type: boolean + required: + - format + additionalProperties: false + responses: + "200": + description: "Export generated. Content-Type varies by `format`: application/pdf, application/fhir+json, or + application/zip." + content: + application/pdf: + schema: + type: string + format: binary + application/fhir+json: + schema: + type: string + format: binary + application/zip: + schema: + type: string + format: binary + "401": &a1 + description: Authentication required or invalid credentials. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": &a2 + description: Request validation failed. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "429": &a3 + description: Rate limit exceeded. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" /api/auth/login: post: tags: @@ -65,24 +215,9 @@ paths: application/json: schema: $ref: "#/components/schemas/LoginResponse" - "401": &a1 - description: Authentication required or invalid credentials. - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorEnvelope" - "422": &a2 - description: Request validation failed. - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorEnvelope" - "429": &a3 - description: Rate limit exceeded. - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorEnvelope" + "401": *a1 + "422": *a2 + "429": *a3 /api/auth/passkey/login-verify: post: tags: @@ -109,9 +244,16 @@ paths: tags: - Auth summary: Rotate refresh token (one-time use) - description: Reuse of a consumed refresh token revokes every refresh token still active for the originating device + description: >- + Reuse of a consumed refresh token revokes every refresh token still active for the originating device (per-device blast radius from v1.4.23). Legacy tokens issued before v1.4.23 with a null deviceId fall back to revoke-all-for-user. + + + On a 401, `meta.errorCode` is a stable machine code so the client can branch terminal re-auth from a transient + blip without parsing the prose `error`: `auth.refresh.reuse` (a consumed token was replayed — device family + revoked, re-pair required), `auth.refresh.revoked` (family revoked out-of-band — re-pair required), + `auth.refresh.invalid` (not found / expired — drop the token and re-authenticate). requestBody: required: true content: @@ -128,6 +270,61 @@ paths: "401": *a1 "422": *a2 "429": *a3 + /api/sync/state: + get: + tags: + - Sync + summary: Sync handshake + window metadata + description: Cheap 'should I sync?' summary. Returns the previous `lastSyncedAt` checkpoint and advances it server-side + on each call. The `sync` block carries the incremental-delta window + tombstone retention so the client reads + them rather than hardcoding. + responses: + "200": + description: Sync state summary. + content: + application/json: + schema: + $ref: "#/components/schemas/SyncStateEnvelope" + "401": *a1 + "422": *a2 + "429": *a3 + /api/sync/changes: + get: + tags: + - Sync + summary: Measurements delta feed + description: "Incremental catch-up after the first-pair backfill (never a replacement for it). Pages over an opaque + keyset cursor; each page carries `tombstones` (soft-deleted rows, keyed on `externalId`) and `upserts` (live + rows). Apply tombstones before upserts within a page. `cursorExpired: true` forces a clean re-init." + parameters: + - in: query + name: cursor + schema: + description: Opaque multi-domain keyset cursor from the previous page. Treat as fully opaque — echo, never parse. Omit + for the initial sync. + type: string + minLength: 1 + maxLength: 2048 + description: Opaque multi-domain keyset cursor from the previous page. Treat as fully opaque — echo, never parse. Omit + for the initial sync. + - in: query + name: limit + schema: + description: Page size, default 200, hard cap 500. + type: integer + minimum: 1 + maximum: 500 + description: Page size, default 200, hard cap 500. + responses: + "200": + description: Delta page. + content: + application/json: + schema: + $ref: "#/components/schemas/SyncChangesEnvelope" + "401": *a1 + "422": *a2 + "429": *a3 /api/measurements: get: tags: @@ -1021,6 +1218,22 @@ paths: - last30days - last90days - allTime + dataClusters: + maxItems: 10 + type: array + items: + type: string + enum: + - cardio + - body + - activity + - workouts + - sleep + - mood + - glucose + - medication + - mobility + - environment responses: "200": description: Saved preferences echoed back. @@ -1639,6 +1852,24 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a2 "429": *a3 + /api/dashboard/snapshot: + get: + tags: + - Dashboard + summary: Unified dashboard first-paint snapshot + description: "Assembles every above-the-fold tile field in one round-trip from the rollup / mood / widget helpers plus a + read-only lift of the pre-generated daily briefing. Two-phase: `tiles` always present, `extras` nullable on a + rollup-coverage miss. No LLM is reachable from this path. Cookie or Bearer auth." + responses: + "200": + description: Dashboard snapshot. + content: + application/json: + schema: + $ref: "#/components/schemas/DashboardSnapshotResponseEnvelope" + "401": *a1 + "422": *a2 + "429": *a3 /api/insights/comprehensive: get: tags: @@ -1835,6 +2066,16 @@ components: enum: - sandbox - production + medicationDelivery: + description: v1.7.0 per-device medication-delivery override. NULL / omitted = inherit the user-level roaming default. + "server" forces server APNs for this device; "client" forces local. Stored + echoed; cron suppression stays + user-level. + anyOf: + - type: string + enum: + - server + - client + - type: "null" required: - token - bundleId @@ -1884,6 +2125,14 @@ components: - OTHER notificationsEnabled: type: boolean + liveActivityEnabled: + description: iOS Live Activity opt-in for this medication's reminders. Default false. The iOS client owns the + ActivityKit lifecycle; the server only stores + echoes the flag. + type: boolean + criticalAlarmEnabled: + description: iOS 26 AlarmKit critical-reminder opt-in. Default false. Critical alarms bypass the device mute switch / + Focus; the server stores the preference only and hangs no server-side behaviour off it. + type: boolean startsOn: description: Date the medication course begins (ISO `YYYY-MM-DD`). Anchors RRULE BYDAY / BYMONTHDAY patterns and the rolling-interval countdown's first window. NULL = active from creation (the legacy implicit behaviour). @@ -1983,6 +2232,25 @@ components: type: integer minimum: 1 maximum: 365 + scheduleType: + description: Schedule type. SCHEDULED (default) = rrule / rolling / legacy cadence. PRN = as-needed (never projected, + reminded, or counted in compliance expected; still loggable via the intake route). CYCLIC = N weeks on / M + weeks off, gating whichever inner cadence the rrule / legacy fields describe. + type: string + enum: + - SCHEDULED + - PRN + - CYCLIC + cyclicOnWeeks: + description: Cyclic "on" weeks (1..52). Required when `scheduleType` is CYCLIC; ignored otherwise. + type: integer + minimum: 1 + maximum: 52 + cyclicOffWeeks: + description: Cyclic "off" weeks (0..52). Required when `scheduleType` is CYCLIC; ignored otherwise. + type: integer + minimum: 0 + maximum: 52 required: - windowStart - windowEnd @@ -2039,6 +2307,14 @@ components: type: boolean notificationsEnabled: type: boolean + liveActivityEnabled: + description: iOS Live Activity opt-in for this medication's reminders. The iOS client owns the ActivityKit lifecycle; + the server only stores + echoes the flag. + type: boolean + criticalAlarmEnabled: + description: iOS 26 AlarmKit critical-reminder opt-in. Critical alarms bypass the device mute switch / Focus; the server + stores the preference only. + type: boolean startsOn: description: Date the medication course begins (ISO `YYYY-MM-DD`). Anchors RRULE BYDAY / BYMONTHDAY patterns and the rolling-interval countdown's first window. NULL = active from creation (the legacy implicit behaviour). @@ -2272,6 +2548,29 @@ components: - type: "null" description: Flexible-rolling interval in days, counted forward from the latest `MedicationIntakeEvent.takenAt`. **Mutually exclusive with `rrule`.** + scheduleType: + type: string + enum: + - SCHEDULED + - PRN + - CYCLIC + description: v1.7.0 schedule-type discriminator. SCHEDULED = rrule / rolling / legacy cadence. PRN = as-needed (never + projected, reminded, or counted in compliance expected; still loggable via the intake route). CYCLIC = N + weeks on / M weeks off, gating whichever inner cadence the rrule / legacy fields describe. + cyclicOnWeeks: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: v1.7.0 cyclic "on" weeks. Only meaningful when `scheduleType` is CYCLIC; null otherwise. + cyclicOffWeeks: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: v1.7.0 cyclic "off" weeks. Only meaningful when `scheduleType` is CYCLIC; null otherwise. required: - id - medicationId @@ -2284,10 +2583,34 @@ components: - reminderGraceMinutes - rrule - rollingIntervalDays + - scheduleType + - cyclicOnWeeks + - cyclicOffWeeks description: Schedule entry attached to a medication. v1.5 promotes `timesOfDay` to first-class and introduces `rrule` (calendar-anchored cadences) and `rollingIntervalDays` (flexible-rolling cadences). The two recurrence primitives are mutually exclusive — enforced by the Zod refine on writes, the route layer, and a DB CHECK - constraint (`medication_schedules_rrule_xor_rolling`). + constraint (`medication_schedules_rrule_xor_rolling`). v1.7.0 adds `scheduleType` (SCHEDULED / PRN / CYCLIC) and + the cyclic on/off-week fields. + ErrorEnvelope: + type: object + properties: + data: + type: "null" + error: + type: string + meta: + type: object + properties: + requestId: + type: string + errorCode: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + description: "Standard error response: data is null, error is human prose." LoginResponse: type: object properties: @@ -2338,26 +2661,6 @@ components: - tokenExpiresAt additionalProperties: false description: Native-client token bundle returned by login + refresh. Web cookie-only callers see only `user`. - ErrorEnvelope: - type: object - properties: - data: - type: "null" - error: - type: string - meta: - type: object - properties: - requestId: - type: string - errorCode: - type: string - additionalProperties: false - required: - - data - - error - additionalProperties: false - description: "Standard error response: data is null, error is human prose." PasskeyLoginVerifyResponse: type: object properties: @@ -2392,6 +2695,496 @@ components: - data - error additionalProperties: false + SyncStateEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/SyncStateResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + SyncStateResponse: + type: object + properties: + userId: + type: string + timezone: + type: string + lastSyncedAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + serverNow: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + measurements: + type: object + properties: + lastUpdatedAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + liveCount: + type: integer + minimum: 0 + maximum: 9007199254740991 + tombstonedCount: + type: integer + minimum: 0 + maximum: 9007199254740991 + required: + - lastUpdatedAt + - liveCount + - tombstonedCount + additionalProperties: false + mood: + type: object + properties: + lastUpdatedAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + liveCount: + type: integer + minimum: 0 + maximum: 9007199254740991 + tombstonedCount: + type: integer + minimum: 0 + maximum: 9007199254740991 + required: + - lastUpdatedAt + - liveCount + - tombstonedCount + additionalProperties: false + intakes: + type: object + properties: + lastUpdatedAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + liveCount: + type: integer + minimum: 0 + maximum: 9007199254740991 + tombstonedCount: + type: integer + minimum: 0 + maximum: 9007199254740991 + required: + - lastUpdatedAt + - liveCount + - tombstonedCount + additionalProperties: false + sync: + type: object + properties: + incrementalWindowDays: + type: integer + exclusiveMinimum: 0 + maximum: 9007199254740991 + description: Days an incremental delta stays valid; tracks the native refresh-token lifetime. Beyond it a device + re-pairs with a full backfill. iOS derives its window from this rather than hardcoding 60. + tombstoneRetentionDays: + type: integer + exclusiveMinimum: 0 + maximum: 9007199254740991 + description: Horizon past which tombstones may be pruned. A cursor older than this gets `cursorExpired` on + `/api/sync/changes`. + required: + - incrementalWindowDays + - tombstoneRetentionDays + additionalProperties: false + description: Sync-window metadata the client reads instead of hardcoding. + required: + - userId + - timezone + - lastSyncedAt + - serverNow + - measurements + - mood + - intakes + - sync + additionalProperties: false + description: iOS SyncMode handshake. Each GET also advances the server-side `lastSyncedAt` checkpoint and returns the + previous value. The cheap 'should I sync?' summary; the durable delta cursor lives on `/api/sync/changes`. + SyncChangesEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/SyncChangesResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + SyncChangesResponse: + type: object + properties: + serverNow: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + cursor: + anyOf: + - type: string + - type: "null" + description: Opaque cursor to echo into the next request. + hasMore: + type: boolean + description: False once the client is caught up as of `serverNow`. + cursorExpired: + type: boolean + description: True when the supplied cursor predates tombstone retention — drop the cursor and do a clean initial sync. + changes: + type: object + properties: + measurements: + type: object + properties: + upserts: + type: array + items: + $ref: "#/components/schemas/SyncMeasurementUpsert" + tombstones: + type: array + items: + $ref: "#/components/schemas/SyncMeasurementTombstone" + required: + - upserts + - tombstones + additionalProperties: false + mood: + type: object + properties: + upserts: + type: array + items: + $ref: "#/components/schemas/SyncMoodUpsert" + tombstones: + type: array + items: + $ref: "#/components/schemas/SyncMoodTombstone" + required: + - upserts + - tombstones + additionalProperties: false + intakes: + type: object + properties: + upserts: + type: array + items: + $ref: "#/components/schemas/SyncIntakeUpsert" + tombstones: + type: array + items: + $ref: "#/components/schemas/SyncIntakeTombstone" + required: + - upserts + - tombstones + additionalProperties: false + required: + - measurements + - mood + - intakes + additionalProperties: false + required: + - serverNow + - cursor + - hasMore + - cursorExpired + - changes + additionalProperties: false + description: "Multi-domain delta page (v1.7.0): measurements + mood + intakes. One opaque multi-domain keyset cursor; + tombstones apply before upserts within each domain. Tombstone identity: measurements key on externalId, mood + + intakes on server id. The iOS consumer is measurements-only this cycle; mood + intakes are forward-prep." + SyncMeasurementUpsert: + type: object + properties: + id: + type: string + type: + type: string + enum: + - WEIGHT + - BLOOD_PRESSURE_SYS + - BLOOD_PRESSURE_DIA + - PULSE + - BODY_FAT + - SLEEP_DURATION + - ACTIVITY_STEPS + - BLOOD_GLUCOSE + - TOTAL_BODY_WATER + - BONE_MASS + - OXYGEN_SATURATION + - HEART_RATE_VARIABILITY + - RESTING_HEART_RATE + - ACTIVE_ENERGY_BURNED + - FLIGHTS_CLIMBED + - WALKING_RUNNING_DISTANCE + - VO2_MAX + - BODY_TEMPERATURE + - FAT_FREE_MASS + - FAT_MASS + - MUSCLE_MASS + - SKIN_TEMPERATURE + - PULSE_WAVE_VELOCITY + - VASCULAR_AGE + - VISCERAL_FAT + - AUDIO_EXPOSURE_ENV + - AUDIO_EXPOSURE_HEADPHONE + - TIME_IN_DAYLIGHT + - WALKING_STEADINESS + - AUDIO_EXPOSURE_EVENT + - RESPIRATORY_RATE + - BODY_MASS_INDEX + - LEAN_BODY_MASS + - WALKING_HEART_RATE_AVERAGE + - WALKING_ASYMMETRY + - WALKING_DOUBLE_SUPPORT + - WALKING_STEP_LENGTH + - WALKING_SPEED + value: + type: number + unit: + type: string + measuredAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + source: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + notes: + anyOf: + - type: string + - type: "null" + externalId: + anyOf: + - type: string + - type: "null" + description: Cross-device dedup key (UUID string or `stats::`). + syncVersion: + type: integer + exclusiveMinimum: 0 + maximum: 9007199254740991 + description: LWW reconciliation counter; echo to keep the mirror monotonic. + updatedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + required: + - id + - type + - value + - unit + - measuredAt + - source + - externalId + - syncVersion + - updatedAt + additionalProperties: false + SyncMeasurementTombstone: + type: object + properties: + id: + type: string + externalId: + anyOf: + - type: string + - type: "null" + description: The identity key the client dedups on for measurements. + syncVersion: + type: integer + exclusiveMinimum: 0 + maximum: 9007199254740991 + deletedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + updatedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + required: + - id + - externalId + - syncVersion + - deletedAt + - updatedAt + additionalProperties: false + description: A soft-deleted measurement. Apply tombstones BEFORE upserts within a page to avoid resurrecting a row. + SyncMoodUpsert: + type: object + properties: + id: + type: string + date: + type: string + description: YYYY-MM-DD anchored to the row's `tz`. + mood: + type: string + score: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + tags: + anyOf: + - type: string + - type: "null" + description: JSON array of tag keys, or null. + note: + anyOf: + - type: string + - type: "null" + moodLoggedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + source: + type: string + syncVersion: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: LWW reconciliation counter; mood is last-writer-wins by it. + updatedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + required: + - id + - date + - mood + - score + - tags + - note + - moodLoggedAt + - source + - syncVersion + - updatedAt + additionalProperties: false + SyncMoodTombstone: + type: object + properties: + id: + type: string + description: Server id — the identity key the client dedups on for mood. + syncVersion: + type: integer + minimum: 0 + maximum: 9007199254740991 + deletedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + updatedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + required: + - id + - syncVersion + - deletedAt + - updatedAt + additionalProperties: false + description: A soft-deleted mood entry, keyed on server `id`. Apply before upserts within the domain page. + SyncIntakeUpsert: + type: object + properties: + id: + type: string + medicationId: + type: string + scheduledFor: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + takenAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + skipped: + type: boolean + source: + type: string + syncVersion: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Reconciliation counter. An intake is immutable; a correction is a tombstone + re-insert. + updatedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + required: + - id + - medicationId + - scheduledFor + - takenAt + - skipped + - source + - syncVersion + - updatedAt + additionalProperties: false + SyncIntakeTombstone: + type: object + properties: + id: + type: string + description: Server id — the identity key the client dedups on for intakes. + syncVersion: + type: integer + minimum: 0 + maximum: 9007199254740991 + deletedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + updatedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + required: + - id + - syncVersion + - deletedAt + - updatedAt + additionalProperties: false + description: A soft-deleted medication intake, keyed on server `id`. Apply before upserts within the domain page. ListMeasurementsResponse: type: object properties: @@ -2991,6 +3784,14 @@ components: type: boolean notificationsEnabled: type: boolean + liveActivityEnabled: + type: boolean + description: v1.7.0 iOS Live Activity opt-in for this medication's reminders. Default false. The iOS client owns the + ActivityKit lifecycle; the server only stores + echoes the flag. + criticalAlarmEnabled: + type: boolean + description: v1.7.0 iOS 26 AlarmKit critical-reminder opt-in. Default false. Critical alarms bypass the device mute + switch / Focus; the server stores the preference only. pausedAt: anyOf: - type: string @@ -3003,6 +3804,16 @@ components: format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" + nextDueAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + description: v1.7.0 server-computed next due instant across all the medication's schedules (earliest + `nextOccurrenceAfter`). Read-only — computed, not stored. NULL when no schedule has an upcoming slot + (paused, one-shot in the past, `endsOn` crossed, every schedule PRN). The list GET is cached 60 s, so a 60 s + staleness is accepted. startsOn: anyOf: - type: string @@ -3057,8 +3868,11 @@ components: - dosesPerUnit - active - notificationsEnabled + - liveActivityEnabled + - criticalAlarmEnabled - pausedAt - snoozedUntil + - nextDueAt - startsOn - endsOn - oneShot @@ -3130,6 +3944,14 @@ components: type: boolean notificationsEnabled: type: boolean + liveActivityEnabled: + type: boolean + description: v1.7.0 iOS Live Activity opt-in for this medication's reminders. Default false. The iOS client owns the + ActivityKit lifecycle; the server only stores + echoes the flag. + criticalAlarmEnabled: + type: boolean + description: v1.7.0 iOS 26 AlarmKit critical-reminder opt-in. Default false. Critical alarms bypass the device mute + switch / Focus; the server stores the preference only. pausedAt: anyOf: - type: string @@ -3142,6 +3964,16 @@ components: format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" + nextDueAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + description: v1.7.0 server-computed next due instant across all the medication's schedules (earliest + `nextOccurrenceAfter`). Read-only — computed, not stored. NULL when no schedule has an upcoming slot + (paused, one-shot in the past, `endsOn` crossed, every schedule PRN). The list GET is cached 60 s, so a 60 s + staleness is accepted. startsOn: anyOf: - type: string @@ -3183,8 +4015,11 @@ components: - dosesPerUnit - active - notificationsEnabled + - liveActivityEnabled + - criticalAlarmEnabled - pausedAt - snoozedUntil + - nextDueAt - startsOn - endsOn - oneShot @@ -3603,6 +4438,22 @@ components: - last30days - last90days - allTime + dataClusters: + maxItems: 10 + type: array + items: + type: string + enum: + - cardio + - body + - activity + - workouts + - sleep + - mood + - glucose + - medication + - mobility + - environment required: - tone - verbosity @@ -3671,6 +4522,22 @@ components: - last30days - last90days - allTime + dataClusters: + maxItems: 10 + type: array + items: + type: string + enum: + - cardio + - body + - activity + - workouts + - sleep + - mood + - glucose + - medication + - mobility + - environment required: - tone - verbosity @@ -4621,6 +5488,248 @@ components: additionalProperties: false description: Single push delivery attempt. Reserved for the v1.4.48+ follow-up that adds the dedicated PushAttempt table — currently always returned as an empty array so the response shape stays stable for iOS consumers. + DashboardSnapshotResponseEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/DashboardSnapshotResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DashboardSnapshotResponse: + type: object + properties: + user: + type: object + properties: + username: + type: string + timezone: + type: string + heightCm: + anyOf: + - type: number + - type: "null" + dateOfBirth: + anyOf: + - type: string + - type: "null" + gender: + anyOf: + - type: string + enum: + - MALE + - FEMALE + - type: "null" + glucoseUnit: + anyOf: + - type: string + - type: "null" + onboardingTourCompleted: + type: boolean + greetingHour: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - username + - timezone + - heightCm + - dateOfBirth + - gender + - glucoseUnit + - onboardingTourCompleted + - greetingHour + additionalProperties: false + layout: + type: object + propertyNames: + type: string + additionalProperties: {} + layoutCatalogue: + type: array + items: + type: object + properties: + id: + type: string + visible: + type: boolean + order: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - id + - visible + - order + additionalProperties: false + description: Full 27-id widget catalogue (server-known + iOS-only) with per-widget visibility + order. iOS-only ids are + appended default-invisible. The web dashboard reads `layout`; this block is the cold-launch seed for the + native client. + metricStates: + type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + value: + type: number + measuredAt: + type: string + unit: + type: string + required: + - value + - measuredAt + - unit + additionalProperties: false + description: "Latest reading per chartable metric, keyed by the iOS `MetricKind` raw value (the non-obvious raws: + `oxygenSaturation`, `totalBodyWater`, `heartRateVariability`, `bodyMassIndex`, `walkingAsymmetryPercentage`, + `walkingDoubleSupportPercentage`, `environmentalAudioExposure`, `headphoneAudioExposure`, + `activeEnergyBurned`). Each entry carries `value`, `measuredAt` (ISO8601), and the canonical `unit`. Types + the user has never logged are omitted." + tiles: + type: object + properties: + summaries: + type: object + propertyNames: + type: string + additionalProperties: {} + lastSeenByType: + type: object + propertyNames: + type: string + additionalProperties: {} + mood: + type: object + properties: + summary: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: {} + - type: "null" + entries: + type: array + items: + type: object + properties: + date: + type: string + score: + type: number + samples: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - date + - score + - samples + additionalProperties: false + required: + - summary + - entries + additionalProperties: false + required: + - summaries + - lastSeenByType + - mood + additionalProperties: false + extras: + anyOf: + - type: object + properties: + bpInTargetPct: + anyOf: + - type: number + - type: "null" + bpInTargetPct7d: + anyOf: + - type: number + - type: "null" + bpInTargetPct30d: + anyOf: + - type: number + - type: "null" + bpInTargetPctAllTime: + anyOf: + - type: number + - type: "null" + bpInTargetPctPriorMonth: + anyOf: + - type: number + - type: "null" + bpInTargetPctPriorYear: + anyOf: + - type: number + - type: "null" + glucoseByContext: + type: object + propertyNames: + type: string + additionalProperties: {} + required: + - bpInTargetPct + - bpInTargetPct7d + - bpInTargetPct30d + - bpInTargetPctAllTime + - bpInTargetPctPriorMonth + - bpInTargetPctPriorYear + - glucoseByContext + additionalProperties: false + - type: "null" + briefing: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: {} + - type: "null" + briefingState: + type: string + enum: + - ready + - preparing + - disabled + briefingUpdatedAt: + anyOf: + - type: string + - type: "null" + generatedAt: + type: string + required: + - user + - layout + - layoutCatalogue + - metricStates + - tiles + - extras + - briefing + - briefingState + - briefingUpdatedAt + - generatedAt + additionalProperties: false + description: Unified above-the-fold dashboard payload. `tiles` always arrives (slim summaries + mood + resolved widget + layout); `extras` (BD-in-target + per-context glucose) is null on a rollup-coverage miss so the strip never + waits on the slowest read. `briefing` is lifted read-only from the pre-generated insight cache — never generated + synchronously — and reports `ready` / `preparing` / `disabled` via `briefingState`. `layoutCatalogue` (full + 27-id widget catalogue) and `metricStates` (latest reading per metric, keyed by iOS `MetricKind` raw value) are + additive cold-launch seeds for the native client; both derive in-process from data already fetched, adding no DB + round-trip. InsightsComprehensiveResponseEnvelope: type: object properties: @@ -4756,6 +5865,29 @@ components: - type: "null" description: Flexible-rolling interval in days, counted forward from the latest `MedicationIntakeEvent.takenAt`. **Mutually exclusive with `rrule`.** + scheduleType: + type: string + enum: + - SCHEDULED + - PRN + - CYCLIC + description: v1.7.0 schedule-type discriminator. SCHEDULED = rrule / rolling / legacy cadence. PRN = as-needed (never + projected, reminded, or counted in compliance expected; still loggable via the intake route). CYCLIC = N + weeks on / M weeks off, gating whichever inner cadence the rrule / legacy fields describe. + cyclicOnWeeks: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: v1.7.0 cyclic "on" weeks. Only meaningful when `scheduleType` is CYCLIC; null otherwise. + cyclicOffWeeks: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: v1.7.0 cyclic "off" weeks. Only meaningful when `scheduleType` is CYCLIC; null otherwise. required: - id - medicationId @@ -4768,11 +5900,15 @@ components: - reminderGraceMinutes - rrule - rollingIntervalDays + - scheduleType + - cyclicOnWeeks + - cyclicOffWeeks additionalProperties: false description: Schedule entry attached to a medication. v1.5 promotes `timesOfDay` to first-class and introduces `rrule` (calendar-anchored cadences) and `rollingIntervalDays` (flexible-rolling cadences). The two recurrence primitives are mutually exclusive — enforced by the Zod refine on writes, the route layer, and a DB CHECK - constraint (`medication_schedules_rrule_xor_rolling`). + constraint (`medication_schedules_rrule_xor_rolling`). v1.7.0 adds `scheduleType` (SCHEDULED / PRN / CYCLIC) and + the cyclic on/off-week fields. DisableCoachFlagOutput: type: object properties: @@ -4851,6 +5987,14 @@ components: type: boolean notificationsEnabled: type: boolean + liveActivityEnabled: + type: boolean + description: v1.7.0 iOS Live Activity opt-in for this medication's reminders. Default false. The iOS client owns the + ActivityKit lifecycle; the server only stores + echoes the flag. + criticalAlarmEnabled: + type: boolean + description: v1.7.0 iOS 26 AlarmKit critical-reminder opt-in. Default false. Critical alarms bypass the device mute + switch / Focus; the server stores the preference only. pausedAt: anyOf: - type: string @@ -4863,6 +6007,16 @@ components: format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" + nextDueAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + description: v1.7.0 server-computed next due instant across all the medication's schedules (earliest + `nextOccurrenceAfter`). Read-only — computed, not stored. NULL when no schedule has an upcoming slot + (paused, one-shot in the past, `endsOn` crossed, every schedule PRN). The list GET is cached 60 s, so a 60 s + staleness is accepted. startsOn: anyOf: - type: string @@ -4902,8 +6056,11 @@ components: - dosesPerUnit - active - notificationsEnabled + - liveActivityEnabled + - criticalAlarmEnabled - pausedAt - snoozedUntil + - nextDueAt - startsOn - endsOn - oneShot diff --git a/messages/de.json b/messages/de.json index 7b7e07700..9e6204a33 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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.", diff --git a/messages/en.json b/messages/en.json index 345a502bb..287eb7e5b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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.", diff --git a/messages/es.json b/messages/es.json index 06db9ed7a..ecd67d9cb 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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.", diff --git a/messages/fr.json b/messages/fr.json index bf586c78b..4c9296dc8 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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.", diff --git a/messages/it.json b/messages/it.json index 17df2fb12..1c1e2e244 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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.", diff --git a/messages/pl.json b/messages/pl.json index d1443e481..375cf2fc1 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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.", diff --git a/package.json b/package.json index 743890c47..4d1871518 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1f932612e..0117a752d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/prisma/migrations/0091_v170_med_flags_and_device_delivery/migration.sql b/prisma/migrations/0091_v170_med_flags_and_device_delivery/migration.sql new file mode 100644 index 000000000..330c83ce6 --- /dev/null +++ b/prisma/migrations/0091_v170_med_flags_and_device_delivery/migration.sql @@ -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; diff --git a/prisma/migrations/0092_v170_schedule_types/migration.sql b/prisma/migrations/0092_v170_schedule_types/migration.sql new file mode 100644 index 000000000..a25eec370 --- /dev/null +++ b/prisma/migrations/0092_v170_schedule_types/migration.sql @@ -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; diff --git a/prisma/migrations/0093_v170_profile_identity_fields/migration.sql b/prisma/migrations/0093_v170_profile_identity_fields/migration.sql new file mode 100644 index 000000000..0aaa135b3 --- /dev/null +++ b/prisma/migrations/0093_v170_profile_identity_fields/migration.sql @@ -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; diff --git a/prisma/migrations/0094_v170_user_unit_preference/migration.sql b/prisma/migrations/0094_v170_user_unit_preference/migration.sql new file mode 100644 index 000000000..a132f36e5 --- /dev/null +++ b/prisma/migrations/0094_v170_user_unit_preference/migration.sql @@ -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; diff --git a/prisma/migrations/0095_v170_reminder_message_time_of_day/migration.sql b/prisma/migrations/0095_v170_reminder_message_time_of_day/migration.sql new file mode 100644 index 000000000..26db49586 --- /dev/null +++ b/prisma/migrations/0095_v170_reminder_message_time_of_day/migration.sql @@ -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"); diff --git a/prisma/migrations/0096_v170_measurement_sync_keyset_index/migration.sql b/prisma/migrations/0096_v170_measurement_sync_keyset_index/migration.sql new file mode 100644 index 000000000..76736f473 --- /dev/null +++ b/prisma/migrations/0096_v170_measurement_sync_keyset_index/migration.sql @@ -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"); diff --git a/prisma/migrations/0097_v170_mood_intake_sync/migration.sql b/prisma/migrations/0097_v170_mood_intake_sync/migration.sql new file mode 100644 index 000000000..ec12c43f1 --- /dev/null +++ b/prisma/migrations/0097_v170_mood_intake_sync/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 11d97cfea..af4015027 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") diff --git a/src/app/__tests__/dashboard-analytics-split.test.ts b/src/app/__tests__/dashboard-analytics-split.test.ts index 6ab16f5a1..227fb7b42 100644 --- a/src/app/__tests__/dashboard-analytics-split.test.ts +++ b/src/app/__tests__/dashboard-analytics-split.test.ts @@ -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", () => { diff --git a/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts b/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts index 6947dbd08..75ca1a0b1 100644 --- a/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts +++ b/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts @@ -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*\{/); }); }); diff --git a/src/app/api/auth/me/coach-prefs/route.ts b/src/app/api/auth/me/coach-prefs/route.ts index 99d9f00e9..75a60780e 100644 --- a/src/app/api/auth/me/coach-prefs/route.ts +++ b/src/app/api/auth/me/coach-prefs/route.ts @@ -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); diff --git a/src/app/api/auth/me/devices/[id]/route.ts b/src/app/api/auth/me/devices/[id]/route.ts index 242fe5020..7d1630300 100644 --- a/src/app/api/auth/me/devices/[id]/route.ts +++ b/src/app/api/auth/me/devices/[id]/route.ts @@ -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(); diff --git a/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts index c16c9d6b4..8de3e2566 100644 --- a/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts +++ b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts @@ -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)( + 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)( + 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({ diff --git a/src/app/api/auth/me/notification-prefs/route.ts b/src/app/api/auth/me/notification-prefs/route.ts index f22c1b1f6..47e21c24f 100644 --- a/src/app/api/auth/me/notification-prefs/route.ts +++ b/src/app/api/auth/me/notification-prefs/route.ts @@ -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 diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 949490a22..e19773251 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -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, }); }); diff --git a/src/app/api/auth/me/unit-preference/__tests__/route.test.ts b/src/app/api/auth/me/unit-preference/__tests__/route.test.ts new file mode 100644 index 000000000..86925d8e1 --- /dev/null +++ b/src/app/api/auth/me/unit-preference/__tests__/route.test.ts @@ -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)( + 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)( + 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)( + 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)( + 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)( + 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)( + 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)(req); + expect(res.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/auth/me/unit-preference/route.ts b/src/app/api/auth/me/unit-preference/route.ts new file mode 100644 index 000000000..854fdbd91 --- /dev/null +++ b/src/app/api/auth/me/unit-preference/route.ts @@ -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); +}); diff --git a/src/app/api/auth/profile/route.ts b/src/app/api/auth/profile/route.ts index 164291eaa..ede9d4faa 100644 --- a/src/app/api/auth/profile/route.ts +++ b/src/app/api/auth/profile/route.ts @@ -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, }); }); diff --git a/src/app/api/auth/refresh/__tests__/error-code.test.ts b/src/app/api/auth/refresh/__tests__/error-code.test.ts new file mode 100644 index 000000000..6f3d268b1 --- /dev/null +++ b/src/app/api/auth/refresh/__tests__/error-code.test.ts @@ -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"); + }); +}); diff --git a/src/app/api/auth/refresh/route.ts b/src/app/api/auth/refresh/route.ts index cb3256813..80fc83a92 100644 --- a/src/app/api/auth/refresh/route.ts +++ b/src/app/api/auth/refresh/route.ts @@ -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", { diff --git a/src/app/api/dashboard/snapshot/__tests__/route.test.ts b/src/app/api/dashboard/snapshot/__tests__/route.test.ts new file mode 100644 index 000000000..a88e5fc80 --- /dev/null +++ b/src/app/api/dashboard/snapshot/__tests__/route.test.ts @@ -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; +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); + }); +}); diff --git a/src/app/api/dashboard/snapshot/route.ts b/src/app/api/dashboard/snapshot/route.ts new file mode 100644 index 000000000..f700e5e21 --- /dev/null +++ b/src/app/api/dashboard/snapshot/route.ts @@ -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 = {}; + const time = async ( + label: string, + builder: () => Promise, + ): Promise => { + 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, + `${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; +}); diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts index 015e00991..39a42b224 100644 --- a/src/app/api/dashboard/summary/route.ts +++ b/src/app/api/dashboard/summary/route.ts @@ -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 }], }, diff --git a/src/app/api/dashboard/widgets/__tests__/route.test.ts b/src/app/api/dashboard/widgets/__tests__/route.test.ts index 6f7f08801..b46247982 100644 --- a/src/app/api/dashboard/widgets/__tests__/route.test.ts +++ b/src/app/api/dashboard/widgets/__tests__/route.test.ts @@ -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 }).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; + +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, + }), + }), + ); + }); +}); diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts index 9b0b6c342..341787b3a 100644 --- a/src/app/api/dashboard/widgets/route.ts +++ b/src/app/api/dashboard/widgets/route.ts @@ -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(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 diff --git a/src/app/api/devices/route.ts b/src/app/api/devices/route.ts index a80a47c73..e1f65196a 100644 --- a/src/app/api/devices/route.ts +++ b/src/app/api/devices/route.ts @@ -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 }, }); diff --git a/src/app/api/doctor-report/availability/route.ts b/src/app/api/doctor-report/availability/route.ts index ca24d22f7..32bf3e2ac 100644 --- a/src/app/api/doctor-report/availability/route.ts +++ b/src/app/api/doctor-report/availability/route.ts @@ -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: { diff --git a/src/app/api/export/full-backup/route.ts b/src/app/api/export/full-backup/route.ts index 469192933..516048198 100644 --- a/src/app/api/export/full-backup/route.ts +++ b/src/app/api/export/full-backup/route.ts @@ -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" }, }), ]); diff --git a/src/app/api/export/health-record/__tests__/route.test.ts b/src/app/api/export/health-record/__tests__/route.test.ts new file mode 100644 index 000000000..6a683a8cb --- /dev/null +++ b/src/app/api/export/health-record/__tests__/route.test.ts @@ -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 }), + }), + ); + }); +}); diff --git a/src/app/api/export/health-record/route.ts b/src/app/api/export/health-record/route.ts new file mode 100644 index 000000000..c4765d2b2 --- /dev/null +++ b/src/app/api/export/health-record/route.ts @@ -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:` 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; + 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; + } +} diff --git a/src/app/api/export/medications/route.ts b/src/app/api/export/medications/route.ts index ab0114399..689593512 100644 --- a/src/app/api/export/medications/route.ts +++ b/src/app/api/export/medications/route.ts @@ -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 = {}; diff --git a/src/app/api/export/mood/route.ts b/src/app/api/export/mood/route.ts index 6a6118bce..1f4343944 100644 --- a/src/app/api/export/mood/route.ts +++ b/src/app/api/export/mood/route.ts @@ -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 = {}; diff --git a/src/app/api/export/route.ts b/src/app/api/export/route.ts index e688a44e3..6b9fc30fe 100644 --- a/src/app/api/export/route.ts +++ b/src/app/api/export/route.ts @@ -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 = diff --git a/src/app/api/gamification/achievements/route.ts b/src/app/api/gamification/achievements/route.ts index a6ab20dc1..43e927ccd 100644 --- a/src/app/api/gamification/achievements/route.ts +++ b/src/app/api/gamification/achievements/route.ts @@ -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: { diff --git a/src/app/api/insights/cards/route.ts b/src/app/api/insights/cards/route.ts index dd7052852..18ed1f860 100644 --- a/src/app/api/insights/cards/route.ts +++ b/src/app/api/insights/cards/route.ts @@ -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) }, }, diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index 43a5a6f95..cbbd44bfb 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -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, }, }); diff --git a/src/app/api/insights/comprehensive/route.ts b/src/app/api/insights/comprehensive/route.ts index 09b85fa7c..7884d9f2f 100644 --- a/src/app/api/insights/comprehensive/route.ts +++ b/src/app/api/insights/comprehensive/route.ts @@ -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, diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index b20dc8d45..f8f9bf91f 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -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), diff --git a/src/app/api/insights/glp1-timeline/route.ts b/src/app/api/insights/glp1-timeline/route.ts index 73fd9b852..26f291608 100644 --- a/src/app/api/insights/glp1-timeline/route.ts +++ b/src/app/api/insights/glp1-timeline/route.ts @@ -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" }, }); diff --git a/src/app/api/insights/targets/route.ts b/src/app/api/insights/targets/route.ts index 46e195659..2c413b3bb 100644 --- a/src/app/api/insights/targets/route.ts +++ b/src/app/api/insights/targets/route.ts @@ -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 }, }); diff --git a/src/app/api/integrations/moodlog/status/route.ts b/src/app/api/integrations/moodlog/status/route.ts index 9c129eb8a..fda320e93 100644 --- a/src/app/api/integrations/moodlog/status/route.ts +++ b/src/app/api/integrations/moodlog/status/route.ts @@ -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. diff --git a/src/app/api/measurements/[id]/__tests__/route.test.ts b/src/app/api/measurements/[id]/__tests__/route.test.ts index c418de739..030d3e3b9 100644 --- a/src/app/api/measurements/[id]/__tests__/route.test.ts +++ b/src/app/api/measurements/[id]/__tests__/route.test.ts @@ -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); diff --git a/src/app/api/measurements/[id]/route.ts b/src/app/api/measurements/[id]/route.ts index 47f85b862..5939d75e9 100644 --- a/src/app/api/measurements/[id]/route.ts +++ b/src/app/api/measurements/[id]/route.ts @@ -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, diff --git a/src/app/api/measurements/__tests__/put-duplicate-timestamp.test.ts b/src/app/api/measurements/__tests__/put-duplicate-timestamp.test.ts index ae9af896d..f7679aa90 100644 --- a/src/app/api/measurements/__tests__/put-duplicate-timestamp.test.ts +++ b/src/app/api/measurements/__tests__/put-duplicate-timestamp.test.ts @@ -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", diff --git a/src/app/api/measurements/by-external-ids/route.ts b/src/app/api/measurements/by-external-ids/route.ts index 5ab75ded7..ced87b1e4 100644 --- a/src/app/api/measurements/by-external-ids/route.ts +++ b/src/app/api/measurements/by-external-ids/route.ts @@ -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 { 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 }, }, }); diff --git a/src/app/api/medications/[id]/cadence/route.ts b/src/app/api/medications/[id]/cadence/route.ts index ceb9a67d8..5bff9ba7a 100644 --- a/src/app/api/medications/[id]/cadence/route.ts +++ b/src/app/api/medications/[id]/cadence/route.ts @@ -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: { diff --git a/src/app/api/medications/[id]/compliance/route.ts b/src/app/api/medications/[id]/compliance/route.ts index ebc2c11d5..d0e04bc0f 100644 --- a/src/app/api/medications/[id]/compliance/route.ts +++ b/src/app/api/medications/[id]/compliance/route.ts @@ -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 = {}; 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, diff --git a/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts index 43e3a1255..971cebb19 100644 --- a/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts @@ -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); diff --git a/src/app/api/medications/[id]/intake/[eventId]/route.ts b/src/app/api/medications/[id]/intake/[eventId]/route.ts index 13343a1a2..7468eaeee 100644 --- a/src/app/api/medications/[id]/intake/[eventId]/route.ts +++ b/src/app/api/medications/[id]/intake/[eventId]/route.ts @@ -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", { diff --git a/src/app/api/medications/[id]/intake/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/__tests__/route.test.ts index 9865f1173..4952aa170 100644 --- a/src/app/api/medications/[id]/intake/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/__tests__/route.test.ts @@ -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 }], }); }); diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index f4ee24d6c..0e886ac42 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -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, }), diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index 0675527eb..a899b8c5b 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -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, + }), }; }), }, diff --git a/src/app/api/medications/intake/__tests__/route.test.ts b/src/app/api/medications/intake/__tests__/route.test.ts index 6b790d92b..c257ee57e 100644 --- a/src/app/api/medications/intake/__tests__/route.test.ts +++ b/src/app/api/medications/intake/__tests__/route.test.ts @@ -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 } }, }); }); }); diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index 57afe44b2..77b31b80e 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -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. diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index 61df5ffb2..f04616a1b 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -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, + }), }; }), }, diff --git a/src/app/api/mood-entries/[id]/__tests__/route.test.ts b/src/app/api/mood-entries/[id]/__tests__/route.test.ts index b42d92559..ea8cbb29d 100644 --- a/src/app/api/mood-entries/[id]/__tests__/route.test.ts +++ b/src/app/api/mood-entries/[id]/__tests__/route.test.ts @@ -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", diff --git a/src/app/api/mood-entries/[id]/route.ts b/src/app/api/mood-entries/[id]/route.ts index 35398919b..c68f8d21c 100644 --- a/src/app/api/mood-entries/[id]/route.ts +++ b/src/app/api/mood-entries/[id]/route.ts @@ -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, diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts index de4efcfca..5afdfba91 100644 --- a/src/app/api/mood-entries/route.ts +++ b/src/app/api/mood-entries/route.ts @@ -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 ? { diff --git a/src/app/api/mood/analytics/route.ts b/src/app/api/mood/analytics/route.ts index 9b911f34b..2d8f69a0d 100644 --- a/src/app/api/mood/analytics/route.ts +++ b/src/app/api/mood/analytics/route.ts @@ -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 }, }); diff --git a/src/app/api/sync/changes/route.ts b/src/app/api/sync/changes/route.ts new file mode 100644 index 000000000..952b8ab56 --- /dev/null +++ b/src/app/api/sync/changes/route.ts @@ -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 }; + } +} diff --git a/src/app/api/sync/state/route.ts b/src/app/api/sync/state/route.ts index 472c7d1ea..18dba6b9b 100644 --- a/src/app/api/sync/state/route.ts +++ b/src/app/api/sync/state/route.ts @@ -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, }, }); }); diff --git a/src/app/api/user/profile/route.ts b/src/app/api/user/profile/route.ts index 60aff0fa9..615285297 100644 --- a/src/app/api/user/profile/route.ts +++ b/src/app/api/user/profile/route.ts @@ -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, }); }); diff --git a/src/app/insights/atemfrequenz/page.tsx b/src/app/insights/atemfrequenz/page.tsx new file mode 100644 index 000000000..9ee4af14e --- /dev/null +++ b/src/app/insights/atemfrequenz/page.tsx @@ -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 ( + } + 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?" + /> + ); +} diff --git a/src/app/insights/blutzucker/page.tsx b/src/app/insights/blutzucker/page.tsx new file mode 100644 index 000000000..504e9c5ff --- /dev/null +++ b/src/app/insights/blutzucker/page.tsx @@ -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 ( + } + 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?" + /> + ); +} diff --git a/src/app/insights/doppelstandphase/page.tsx b/src/app/insights/doppelstandphase/page.tsx new file mode 100644 index 000000000..749f28e03 --- /dev/null +++ b/src/app/insights/doppelstandphase/page.tsx @@ -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 ( + } + 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?" + /> + ); +} diff --git a/src/app/insights/fettfreie-masse/page.tsx b/src/app/insights/fettfreie-masse/page.tsx new file mode 100644 index 000000000..a440c452a --- /dev/null +++ b/src/app/insights/fettfreie-masse/page.tsx @@ -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 ( + } + 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?" + /> + ); +} diff --git a/src/app/insights/fettmasse/page.tsx b/src/app/insights/fettmasse/page.tsx new file mode 100644 index 000000000..389188e27 --- /dev/null +++ b/src/app/insights/fettmasse/page.tsx @@ -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 ( + } + 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?" + /> + ); +} diff --git a/src/app/insights/gangasymmetrie/page.tsx b/src/app/insights/gangasymmetrie/page.tsx new file mode 100644 index 000000000..5b6ecb548 --- /dev/null +++ b/src/app/insights/gangasymmetrie/page.tsx @@ -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 ( + } + 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?" + /> + ); +} diff --git a/src/app/insights/gangstabilitaet/page.tsx b/src/app/insights/gangstabilitaet/page.tsx new file mode 100644 index 000000000..c3e88c47d --- /dev/null +++ b/src/app/insights/gangstabilitaet/page.tsx @@ -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 ( + } + 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?" + /> + ); +} diff --git a/src/app/insights/gefaessalter/page.tsx b/src/app/insights/gefaessalter/page.tsx new file mode 100644 index 000000000..939f14ca1 --- /dev/null +++ b/src/app/insights/gefaessalter/page.tsx @@ -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 ( + } + 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?" + /> + ); +} diff --git a/src/app/insights/gehgeschwindigkeit/page.tsx b/src/app/insights/gehgeschwindigkeit/page.tsx new file mode 100644 index 000000000..0602540a0 --- /dev/null +++ b/src/app/insights/gehgeschwindigkeit/page.tsx @@ -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 ( + } + 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?" + /> + ); +} diff --git a/src/app/insights/gehpuls/page.tsx b/src/app/insights/gehpuls/page.tsx new file mode 100644 index 000000000..1f150cb8c --- /dev/null +++ b/src/app/insights/gehpuls/page.tsx @@ -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 ( + } + 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?" + /> + ); +} diff --git a/src/app/insights/gehstrecke/page.tsx b/src/app/insights/gehstrecke/page.tsx new file mode 100644 index 000000000..f43bf76c8 --- /dev/null +++ b/src/app/insights/gehstrecke/page.tsx @@ -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 ( + } + 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?" + /> + ); +} diff --git a/src/app/insights/hauttemperatur/page.tsx b/src/app/insights/hauttemperatur/page.tsx new file mode 100644 index 000000000..569e73217 --- /dev/null +++ b/src/app/insights/hauttemperatur/page.tsx @@ -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 ( + } + 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?" + /> + ); +} diff --git a/src/app/insights/knochenmasse/page.tsx b/src/app/insights/knochenmasse/page.tsx new file mode 100644 index 000000000..e427f9a36 --- /dev/null +++ b/src/app/insights/knochenmasse/page.tsx @@ -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 ( + } + 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?" + /> + ); +} diff --git a/src/app/insights/koerperwasser/page.tsx b/src/app/insights/koerperwasser/page.tsx new file mode 100644 index 000000000..8daf85812 --- /dev/null +++ b/src/app/insights/koerperwasser/page.tsx @@ -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 ( + } + 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?" + /> + ); +} diff --git a/src/app/insights/kopfhoererpegel/page.tsx b/src/app/insights/kopfhoererpegel/page.tsx new file mode 100644 index 000000000..ef932d51f --- /dev/null +++ b/src/app/insights/kopfhoererpegel/page.tsx @@ -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 ( + } + 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?" + /> + ); +} diff --git a/src/app/insights/laermbelastung/page.tsx b/src/app/insights/laermbelastung/page.tsx new file mode 100644 index 000000000..f64802435 --- /dev/null +++ b/src/app/insights/laermbelastung/page.tsx @@ -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 ( + } + 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?" + /> + ); +} diff --git a/src/app/insights/laermereignisse/page.tsx b/src/app/insights/laermereignisse/page.tsx new file mode 100644 index 000000000..458822f9c --- /dev/null +++ b/src/app/insights/laermereignisse/page.tsx @@ -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 ( + } + 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?" + /> + ); +} diff --git a/src/app/insights/magermasse/page.tsx b/src/app/insights/magermasse/page.tsx new file mode 100644 index 000000000..42eaf7a7d --- /dev/null +++ b/src/app/insights/magermasse/page.tsx @@ -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 ( + } + 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?" + /> + ); +} diff --git a/src/app/insights/muskelmasse/page.tsx b/src/app/insights/muskelmasse/page.tsx new file mode 100644 index 000000000..272adc567 --- /dev/null +++ b/src/app/insights/muskelmasse/page.tsx @@ -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 ( + } + 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?" + /> + ); +} diff --git a/src/app/insights/pulswellengeschwindigkeit/page.tsx b/src/app/insights/pulswellengeschwindigkeit/page.tsx new file mode 100644 index 000000000..2e4707c4b --- /dev/null +++ b/src/app/insights/pulswellengeschwindigkeit/page.tsx @@ -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 ( + } + 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?" + /> + ); +} diff --git a/src/app/insights/schrittlaenge/page.tsx b/src/app/insights/schrittlaenge/page.tsx new file mode 100644 index 000000000..aecb395b7 --- /dev/null +++ b/src/app/insights/schrittlaenge/page.tsx @@ -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 ( + } + 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?" + /> + ); +} diff --git a/src/app/insights/stockwerke/page.tsx b/src/app/insights/stockwerke/page.tsx new file mode 100644 index 000000000..006d857c4 --- /dev/null +++ b/src/app/insights/stockwerke/page.tsx @@ -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 ( + } + 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?" + /> + ); +} diff --git a/src/app/insights/tageslicht/page.tsx b/src/app/insights/tageslicht/page.tsx new file mode 100644 index 000000000..db74e3cdc --- /dev/null +++ b/src/app/insights/tageslicht/page.tsx @@ -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 ( + } + 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?" + /> + ); +} diff --git a/src/app/insights/viszeralfett/page.tsx b/src/app/insights/viszeralfett/page.tsx new file mode 100644 index 000000000..bc1bf20d4 --- /dev/null +++ b/src/app/insights/viszeralfett/page.tsx @@ -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 ( + } + 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?" + /> + ); +} diff --git a/src/app/medications/[id]/history/page.tsx b/src/app/medications/[id]/history/page.tsx index 746a11d9e..5de206a80 100644 --- a/src/app/medications/[id]/history/page.tsx +++ b/src/app/medications/[id]/history/page.tsx @@ -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 + * ``); 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 - // ``. + const isGlp1 = medication?.treatmentClass === "GLP1"; + return (
+ {/* Back to the medication detail page — history is a drill-down + OF the medication, not a sibling of the list. */} {medication && ( -
-
+
+

{medication.name}

-

- {medication.dose} +

{medication.dose}

+

+ {t("medications.detail.history.subtitle")}

+ {/* De-emphasised import — ghost / muted, not a prominent CTA. */} +
)} - {/* 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" && ( - - )} - - {/* 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" && ( - - )} - - {/* 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" && ( - )} - {/* 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" && ( - + {/* 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 && ( +
+ + {t("medications.detail.history.drugLevelDisclosure")} + +
+ + +
+
)} - {/* 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 && } + {importOpen && ( + setImportOpen(false)} + /> + )}
); } diff --git a/src/app/medications/[id]/page.tsx b/src/app/medications/[id]/page.tsx index 55ff62c26..fdfeb589e 100644 --- a/src/app/medications/[id]/page.tsx +++ b/src/app/medications/[id]/page.tsx @@ -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({ - {/* 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. */} 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 diff --git a/src/app/page.tsx b/src/app/page.tsx index bde5a4540..f2b0ab75b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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(() => { + // 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 + | 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 | 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 `` 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(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 ( } diff --git a/src/components/charts/__tests__/health-chart-range.test.tsx b/src/components/charts/__tests__/health-chart-range.test.tsx index 0610579e1..f795238b0 100644 --- a/src/components/charts/__tests__/health-chart-range.test.tsx +++ b/src/components/charts/__tests__/health-chart-range.test.tsx @@ -129,9 +129,12 @@ describe(" — bounded range fetch", () => { const key = (globalThis as unknown as { __lastQueryKey?: unknown[] }) .__lastQueryKey; expect(key).toBeDefined(); - // Last two slots are `from` / `to`. Both must be ISO date strings. + // v1.7.0 — the key now trails with the display `valueScale` (default + // 1), so `from` / `to` sit at length-3 / length-2 and the scale at + // length-1. The window slots must still be ISO date strings. const isoRe = /\d{4}-\d{2}-\d{2}T/; + expect(String(key![key!.length - 3])).toMatch(isoRe); expect(String(key![key!.length - 2])).toMatch(isoRe); - expect(String(key![key!.length - 1])).toMatch(isoRe); + expect(key![key!.length - 1]).toBe(1); }); }); diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 366a03553..76489219a 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -173,6 +173,20 @@ interface HealthChartProps { * behaviour bit-for-bit. */ userTimezone?: string; + /** + * v1.7.0 — display-time scalar applied to every raw value before it + * enters the chart pipeline (bucketing, y-domain, personal baseline, + * trend, tooltip). Used by the unit-fix mounts to render a metric in + * a derived display unit (e.g. WALKING_SPEED m/s → km/h via + * `valueScale={3.6}`) while raw storage stays canonical SI. + * + * Defaults to `1` — the identity scale — so every existing chart + * renders byte-identical to the pre-v1.7.0 behaviour. Recharts is + * untouched; the scale is folded into the value at the single read + * boundary so all downstream math operates on the scaled series + * uniformly. + */ + valueScale?: number; } interface ChartDataPoint { @@ -465,6 +479,7 @@ export function HealthChart({ annotations, verticalMarkers, userTimezone = "Europe/Berlin", + valueScale = 1, }: HealthChartProps) { const { isAuthenticated, user } = useAuth(); const { t, locale } = useTranslations(); @@ -567,6 +582,9 @@ export function HealthChart({ // re-running the unbounded walk. fetchWindow.from, fetchWindow.to, + // v1.7.0 — re-key when the display scale changes so a km/h chart + // never reads a m/s-scaled sibling out of the cache. + valueScale, ), // v1.4.28 FB-D2 — cache the bounded window for a minute so tab // navigation between insights sub-pages does not re-fire every @@ -628,7 +646,12 @@ export function HealthChart({ const page = (json.data?.measurements ?? []) as MeasurementApiRow[]; for (const measurement of page) { - const rawValue = measurement.value; + // v1.7.0 — fold the display-time scale into the raw value at + // the single read boundary so bucketing, the y-domain, the + // personal baseline, the trend, and the tooltip all operate + // on the scaled series uniformly. `valueScale` defaults to 1 + // (identity), so non-unit-fixed charts read byte-identical. + const rawValue = measurement.value * valueScale; const value = valueMode === "bmi" ? bmiDivisor diff --git a/src/components/insights/coach-panel/coach-settings-sheet.tsx b/src/components/insights/coach-panel/coach-settings-sheet.tsx index 5d442cf36..c08338b31 100644 --- a/src/components/insights/coach-panel/coach-settings-sheet.tsx +++ b/src/components/insights/coach-panel/coach-settings-sheet.tsx @@ -29,7 +29,10 @@ import { Skeleton } from "@/components/ui/skeleton"; import { useTranslations } from "@/lib/i18n/context"; import { useCoachPrefs } from "@/hooks/use-coach-prefs"; import { + DEFAULT_COACH_CLUSTERS, DEFAULT_COACH_PREFS, + coachDataClusterEnum, + type CoachDataCluster, type CoachDefaultWindow, type CoachExcludeMetric, type CoachPrefs, @@ -97,6 +100,13 @@ const DEFAULT_WINDOW_OPTIONS: CoachDefaultWindow[] = [ "allTime", ]; +// v1.7.0 — clustered, opt-in data sources. Order matches the design +// sketch (default-on clinical clusters first). Each row shows the +// cluster label + a member-preview hint + a toggle. Toggling writes +// `dataClusters`; the snapshot builder expands the enabled set into the +// source set, then subtracts the existing `excludeMetrics` post-filter. +const CLUSTER_OPTIONS: CoachDataCluster[] = coachDataClusterEnum.options; + export function CoachSettingsSheet({ open, onOpenChange, @@ -146,6 +156,27 @@ export function CoachSettingsSheet({ })); } + // v1.7.0 — the persisted `dataClusters` may be `undefined` (the user + // never opened the picker). Materialise the legacy default set the + // first time the user toggles a cluster so the on-screen switches + // reflect what the snapshot builder actually resolves. + function toggleCluster(cluster: CoachDataCluster, next: boolean) { + setDraft((prev) => { + const current = prev.dataClusters ?? Array.from(DEFAULT_COACH_CLUSTERS); + const updated = next + ? Array.from(new Set([...current, cluster])) + : current.filter((c) => c !== cluster); + return { ...prev, dataClusters: updated }; + }); + } + + // The switch state mirrors the resolved set: undefined → legacy + // defaults, otherwise the explicit array (including the empty + // "everything off" state). + const enabledClusters = new Set( + draft.dataClusters ?? DEFAULT_COACH_CLUSTERS, + ); + // Render a skeleton shell while the persisted prefs are loading so // we never render the form against `DEFAULT_COACH_PREFS` and snap // the controls when the fetch resolves a moment later. @@ -326,7 +357,51 @@ export function CoachSettingsSheet({
- {/* Exclude metrics */} + {/* v1.7.0 — clustered, opt-in data sources. */} +
+ +

+ {t("insights.coach.settingsClustersHint")} +

+
    + {CLUSTER_OPTIONS.map((cluster) => { + const id = `coach-prefs-cluster-${cluster}`; + const checked = enabledClusters.has(cluster); + return ( +
  • +
    + + + {t(`insights.coach.cluster.${cluster}.hint`)} + +
    + toggleCluster(cluster, next)} + /> +
  • + ); + })} +
+
+ + {/* Exclude metrics — advanced fine-tune. Subtracts a single + metric inside an otherwise-enabled cluster. */}
- {/* rare */} - + - {/* destructive */} - onOpenChange(false)} - /> + + + + {/* REMINDERS — notifications + reminder window */} + + + + + + + + + {/* LIFECYCLE — pause/resume + end course + phases */} + + onOpenChange(false)} + /> + + {isGlp1 && ( + <> + + + + )} + + + {/* DANGER ZONE — purge + delete */} + + onOpenChange(false)} + /> +
); diff --git a/src/components/medications/intake-history-editable.tsx b/src/components/medications/intake-history-editable.tsx new file mode 100644 index 000000000..85fb6fe9b --- /dev/null +++ b/src/components/medications/intake-history-editable.tsx @@ -0,0 +1,287 @@ +"use client"; + +/** + * v1.7.0 — shared editable intake-history surface. + * + * Extracted from `` so the detail-page preview + * (14-row, `takenAt` sort) and the full-history view (25-row, + * `scheduledFor` sort) share one state machine: multi-select + * bulk-delete, per-row edit (``) and per-row delete + * (``). The component owns the table + every editing + * dialog; it does NOT own the CSV-import dialog (that stays lifted to + * the hosting page so it mounts exactly once). + * + * Both surfaces render the same ``; the wrapper + * just changes `pageSize` + `defaultSortBy`. The full-history route + * defaults to `scheduledFor desc` so skipped rows (`takenAt: null`) + * never float to the top of the table (O-1). + */ + +import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { Trash2 } from "lucide-react"; +import { toast } from "sonner"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { IntakeEditDialog } from "@/components/medications/intake-edit-dialog"; +import { + IntakeHistoryListV2, + type IntakeEvent, + type IntakeHistorySortKey, +} from "@/components/medications/intake-history-list-v2"; +import { useTranslations } from "@/lib/i18n/context"; +import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys"; + +export interface IntakeHistoryEditableProps { + medicationId: string; + /** Override the default page size — preview 14, full history 25. */ + pageSize?: number; + /** + * Initial sort column. The full-history view passes `"scheduledFor"` + * so the chronological order stays clean even for skipped rows + * (`takenAt: null`); the preview keeps the legacy `"takenAt"`. + */ + defaultSortBy?: IntakeHistorySortKey; +} + +export function IntakeHistoryEditable({ + medicationId, + pageSize, + defaultSortBy, +}: IntakeHistoryEditableProps) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + + const [selected, setSelected] = useState>(new Set()); + const [editingEvent, setEditingEvent] = useState(null); + const [pendingDeleteId, setPendingDeleteId] = useState(null); + const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false); + const [bulkBusy, setBulkBusy] = useState(false); + const [rowDeleteBusy, setRowDeleteBusy] = useState(false); + + function onToggle(id: string) { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + } + + function clearSelection() { + setSelected(new Set()); + } + + async function confirmBulkDelete() { + if (bulkBusy || selected.size === 0) return; + setBulkBusy(true); + const ids = Array.from(selected); + try { + const res = await fetch( + `/api/medications/${medicationId}/intake/bulk-delete`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ eventIds: ids }), + }, + ); + if (!res.ok) { + toast.error(t("medications.detail.intake.bulkDelete.failed")); + return; + } + await invalidateKeys(queryClient, medicationDependentKeys); + toast.success(t("medications.detail.intake.bulkDelete.toast")); + setBulkConfirmOpen(false); + clearSelection(); + } catch { + toast.error(t("medications.detail.intake.bulkDelete.failed")); + } finally { + setBulkBusy(false); + } + } + + async function confirmRowDelete() { + if (rowDeleteBusy || !pendingDeleteId) return; + setRowDeleteBusy(true); + const id = pendingDeleteId; + try { + const res = await fetch(`/api/medications/${medicationId}/intake/${id}`, { + method: "DELETE", + }); + if (!res.ok) { + toast.error(t("medications.detail.intake.deleteRow.failed")); + return; + } + await invalidateKeys(queryClient, medicationDependentKeys); + toast.success(t("medications.detail.intake.deleteRow.toast")); + setPendingDeleteId(null); + // Drop the deleted row from the pending selection so the + // bulk-toolbar count stays honest. + setSelected((prev) => { + if (!prev.has(id)) return prev; + const next = new Set(prev); + next.delete(id); + return next; + }); + } catch { + toast.error(t("medications.detail.intake.deleteRow.failed")); + } finally { + setRowDeleteBusy(false); + } + } + + return ( + <> +
+ {selected.size > 0 && ( +
+ + {t("medications.detail.intake.bulkDelete.selectionCount", { + count: selected.size, + })} + +
+ + +
+
+ )} + + +
+ + setEditingEvent(null)} + /> + + { + if (!open) setBulkConfirmOpen(false); + }} + > + + + + {t("medications.detail.intake.bulkDelete.confirmTitle")} + + + {t("medications.detail.intake.bulkDelete.confirmBody", { + count: selected.size, + })} + + + + + {t("medications.detail.intake.bulkDelete.cancelButton")} + + { + e.preventDefault(); + void confirmBulkDelete(); + }} + disabled={bulkBusy} + aria-busy={bulkBusy || undefined} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {t("medications.detail.intake.bulkDelete.confirmAction")} + + + + + + { + if (!open) setPendingDeleteId(null); + }} + > + + + + {t("medications.detail.intake.deleteRow.confirmTitle")} + + + {t("medications.detail.intake.deleteRow.confirmBody")} + + + + + {t("medications.detail.intake.bulkDelete.cancelButton")} + + { + e.preventDefault(); + void confirmRowDelete(); + }} + disabled={rowDeleteBusy} + aria-busy={rowDeleteBusy || undefined} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {t("medications.detail.intake.deleteRow.confirmAction")} + + + + + + ); +} diff --git a/src/components/medications/intake-history-list-v2.tsx b/src/components/medications/intake-history-list-v2.tsx index 341e0ffd9..1053285cf 100644 --- a/src/components/medications/intake-history-list-v2.tsx +++ b/src/components/medications/intake-history-list-v2.tsx @@ -123,9 +123,17 @@ interface IntakeHistoryListV2Props { * v1.5.5 F-1 C-2 — selection contract; see `IntakeHistoryListV2Selection`. */ selection?: IntakeHistoryListV2Selection; + /** + * v1.7.0 — initial sort column. Defaults to `"takenAt"` to preserve + * the detail-page preview behaviour; the full-history view passes + * `"scheduledFor"` so skipped rows (`takenAt: null`) never float to + * the top of the descending order (O-1). + */ + defaultSortBy?: IntakeHistorySortKey; } -type SortKey = "takenAt" | "scheduledFor"; +export type IntakeHistorySortKey = "takenAt" | "scheduledFor"; +type SortKey = IntakeHistorySortKey; const DEFAULT_PAGE_SIZE = 25; /** @@ -142,6 +150,7 @@ export function IntakeHistoryListV2({ onEditIntake, onDeleteIntake, selection, + defaultSortBy = "takenAt", }: IntakeHistoryListV2Props) { const { isAuthenticated } = useAuth(); const { t } = useTranslations(); @@ -153,7 +162,7 @@ export function IntakeHistoryListV2({ const showRowActions = Boolean(onEditIntake || onDeleteIntake); const showSelection = selection?.mode === "multi"; - const [sortBy, setSortBy] = useState("takenAt"); + const [sortBy, setSortBy] = useState(defaultSortBy); const [sortDir, setSortDir] = useState<"asc" | "desc">("desc"); const [page, setPage] = useState(0); @@ -326,9 +335,21 @@ export function IntakeHistoryListV2({ )} - {event.takenAt - ? formatters.dateTime(event.takenAt) - : "—"} + {/* v1.7.0 — the date column is always populated: + taken rows show `takenAt`, skipped rows fall + back to `scheduledFor` with a muted + "(planned)" suffix so the chronological + order stays clean and no `—` row floats. */} + {event.takenAt ? ( + formatters.dateTime(event.takenAt) + ) : ( + <> + {formatters.dateTime(event.scheduledFor)}{" "} + + ({t("medications.detail.history.plannedSuffix")}) + + + )} {formatters.dateTime(event.scheduledFor)} @@ -386,7 +407,9 @@ export function IntakeHistoryListV2({ onSelect={() => onEditIntake(event)} data-slot="intake-history-row-edit" > - {t("medications.detail.intake.rowActions.edit")} + {t( + "medications.detail.intake.rowActions.edit", + )} )} {onDeleteIntake && ( diff --git a/src/components/medications/medication-detail-header.tsx b/src/components/medications/medication-detail-header.tsx index f014b59a8..0528eb419 100644 --- a/src/components/medications/medication-detail-header.tsx +++ b/src/components/medications/medication-detail-header.tsx @@ -5,10 +5,12 @@ * * Renders the drug name as the H1, dose as a muted sub-line, and a * status row carrying a Dracula-token dot + always-visible label - * (Aktiv / Pausiert / Beendet) and an edit pencil that opens the - * wizard with the `"name"` intent (Step 1 landing). The DOM order is - * `name → dose → status → edit` so the screen reader announces the - * drug first and the affordance last (C-E4-3). + * (Aktiv / Pausiert / Beendet) and a three-button action row: Edit + * (pencil, opens the wizard at Step 1), History (clock, routes to the + * full intake-history view) and Advanced (sliders, opens the advanced + * settings sheet). The DOM order is `name → dose → status → edit → + * history → advanced` so the screen reader announces the drug first + * and the affordances last (C-E4-3). * * The status pill text is always rendered; the dot carries * `aria-hidden="true"` and reads from Dracula tokens via @@ -18,7 +20,7 @@ * destructive zone, not on the header (E-2 M-1). */ -import { Pencil, Settings2 } from "lucide-react"; +import { History, Pencil, SlidersHorizontal } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -36,9 +38,16 @@ export interface MedicationDetailHeaderProps { */ onEditPlan: () => void; /** - * v1.6.0 — the gear button opens the `` - * (notifications / API tokens / Phasen / destructive zone). Kept as - * a discreet secondary affordance beside the primary edit button. + * v1.7.0 — the History (clock) button routes directly to the full + * intake-history view. A `() => void` so the page owns the + * `router.push`; an icon-only button (not an anchor) since it carries + * an `aria-label`. + */ + onOpenHistory: () => void; + /** + * v1.7.0 — the sliders button opens the `` + * (Data / Reminders / Lifecycle / Danger zone). Kept as a discreet + * secondary affordance beside the primary edit button. */ onOpenAdvanced: () => void; } @@ -65,6 +74,7 @@ export function MedicationDetailHeader({ active, endsOn, onEditPlan, + onOpenHistory, onOpenAdvanced, }: MedicationDetailHeaderProps) { const { t } = useTranslations(); @@ -105,7 +115,11 @@ export function MedicationDetailHeader({ -
+ {/* v1.7.0 — three-button row: Edit (primary, labelled) → + History (ghost icon, direct to /history) → Advanced (ghost + icon, sliders). DOM order primary → read → config. All carry a + 44px touch target on mobile. */} +
+
diff --git a/src/components/medications/sections/destructive-zone-section.tsx b/src/components/medications/sections/destructive-zone-section.tsx index 2a6d59205..16888ed9a 100644 --- a/src/components/medications/sections/destructive-zone-section.tsx +++ b/src/components/medications/sections/destructive-zone-section.tsx @@ -3,10 +3,10 @@ /** * v1.5.5 D-3 §9.8 — Verwaltung & Gefahrenzone. * - * One heading, two cards under it. Card A (`Verwaltung`) carries the - * reversible actions (Pausieren + Beenden); Card B (`Gefahrenzone`, - * `border-destructive/40`) carries the irreversible ones (Verlauf - * löschen + Medikament löschen). + * v1.7.0 — split into two reusable bodies so the redesigned + * `` can slot them under different groups: + * `` (Pause + End → Lifecycle group) and + * `` (Purge + Delete → Danger zone group). * * Mutation contracts per D-3 §3: * @@ -46,51 +46,36 @@ import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; import { Switch } from "@/components/ui/switch"; -import { MedicationDetailSection } from "@/components/medications/medication-detail-section"; import { useTranslations } from "@/lib/i18n/context"; import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys"; -export interface DestructiveZoneSectionProps { - medicationId: string; - medicationName: string; - active: boolean; - intakeCount: number; - /** - * v1.5.6 G-1 §5 — fires after a non-navigating destructive success - * (Tier 1 pause/resume, Tier 2 end, Tier 3a purge) so a hosting - * sheet can close itself. Tier 3b delete navigates instead and does - * not call this. Absent on the standalone surface. - */ - onAfterAction?: () => void; -} - const PAUSE_SWITCH_ID = "medication-detail-pause-switch"; const PAUSE_TITLE_ID = "medication-detail-pause-title"; const PAUSE_HELPER_ID = "medication-detail-pause-helper"; -export function DestructiveZoneSection({ +/** + * v1.7.0 — reversible lifecycle controls: pause/resume + end course. + * Bare (no section / card chrome); the caller supplies its own grouping. + */ +export function LifecycleManageBody({ medicationId, medicationName, active, - intakeCount, onAfterAction, -}: DestructiveZoneSectionProps) { +}: { + medicationId: string; + medicationName: string; + active: boolean; + onAfterAction?: () => void; +}) { const { t } = useTranslations(); - const router = useRouter(); const queryClient = useQueryClient(); const [paused, setPaused] = useState(!active); const [tier1Busy, setTier1Busy] = useState(false); - const [endDialogOpen, setEndDialogOpen] = useState(false); const [tier2Busy, setTier2Busy] = useState(false); - const [purgeDialogOpen, setPurgeDialogOpen] = useState(false); - const [tier3aBusy, setTier3aBusy] = useState(false); - - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [tier3bBusy, setTier3bBusy] = useState(false); - async function tier1Toggle(pauseNext: boolean) { if (tier1Busy) return; setTier1Busy(true); @@ -149,14 +134,131 @@ export function DestructiveZoneSection({ } } + return ( +
+ + + + +
+
+

+ {t("medications.detail.zone.end.title")} +

+

+ {t("medications.detail.zone.end.helper")} +

+
+ +
+ + {/* Tier 2 — Beenden confirm */} + + + + + {t("medications.detail.zone.end.dialogTitle", { + name: medicationName, + })} + + + {t("medications.detail.zone.end.dialogBody")} + + + + {t("common.cancel")} + { + e.preventDefault(); + void tier2End(); + }} + disabled={tier2Busy} + aria-busy={tier2Busy || undefined} + className="font-semibold" + > + {tier2Busy && ( + + + + +
+ ); +} + +/** + * v1.7.0 — irreversible danger zone: purge intake history + delete the + * medication. Wrapped in a `border-destructive/40` card to keep the + * visual warning convention. + */ +export function DangerZoneBody({ + medicationId, + medicationName, + intakeCount, + onAfterAction, +}: { + medicationId: string; + medicationName: string; + intakeCount: number; + onAfterAction?: () => void; +}) { + const { t } = useTranslations(); + const router = useRouter(); + const queryClient = useQueryClient(); + + const [purgeDialogOpen, setPurgeDialogOpen] = useState(false); + const [tier3aBusy, setTier3aBusy] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [tier3bBusy, setTier3bBusy] = useState(false); + async function tier3aPurge() { if (tier3aBusy) return; setTier3aBusy(true); try { - const res = await fetch( - `/api/medications/${medicationId}/intake/purge`, - { method: "DELETE" }, - ); + const res = await fetch(`/api/medications/${medicationId}/intake/purge`, { + method: "DELETE", + }); if (!res.ok) { toast.error(t("medications.detail.zone.purge.failed")); return; @@ -197,154 +299,54 @@ export function DestructiveZoneSection({ } return ( - -
- {/* Card A — Verwaltung (reversible) */} - - - - - -
-
-

- {t("medications.detail.zone.end.title")} -

-

- {t("medications.detail.zone.end.helper")} -

-
- -
-
- - {/* Card B — Gefahrenzone (irreversible) */} - +
+

+ {t("medications.detail.zone.purge.title")} +

+

+ {t("medications.detail.zone.purge.helper", { count: intakeCount })} +

+
+ -
- - - -
-
-

- {t("medications.detail.zone.delete.title")} -

-

- {t("medications.detail.zone.delete.helper")} -

-
- -
- +
+ ); } + diff --git a/src/components/medications/sections/intake-history-preview.tsx b/src/components/medications/sections/intake-history-preview.tsx index 6bbf13dad..116d1213a 100644 --- a/src/components/medications/sections/intake-history-preview.tsx +++ b/src/components/medications/sections/intake-history-preview.tsx @@ -3,51 +3,24 @@ /** * v1.5.5 D-3 §9.5 — Intake-history preview. * - * Section chrome via ``. Header CTA carries - * the only CSV-import trigger in the app (the v1.5.4 per-card kebab - * went away). The body re-uses `` so the v1.5.4 - * table + sort + pagination stay byte-identical; the preview adds a - * footer link to the full `/medications/[id]/history` route. + * Section chrome via ``. Header CTA carries a + * CSV-import trigger. The body re-uses the shared + * `` (extracted in v1.7.0) so the table + sort + + * pagination + multi-select bulk-delete + per-row edit/delete stay + * identical between this preview and the full-history view. * - * v1.5.5 F-1 C-2 add-ons (Features 15 + 16 from I-1): - * - Multi-select bulk-delete: the preview drives the - * `` selection contract and renders a - * bulk-delete toolbar above the table when the user has at - * least one row selected. POSTs to the v1.5.5 bulk-delete - * endpoint then fires `medicationDependentKeys` so the inline - * compliance tile + rollup-tier dashboard chart converge in one - * tick. - * - Per-row kebab: Bearbeiten opens an inline `` - * (PUT `/api/medications/{id}/intake/{eventId}`); Löschen fires - * a single-step `` and DELETEs the same row. + * v1.7.0 — the footer "View full history →" link is gone; the detail + * header's History button now routes directly to the full view, so the + * redundant link is removed (R-medui §4.3). */ -import { useState } from "react"; -import Link from "next/link"; -import { useQueryClient } from "@tanstack/react-query"; -import { Trash2, Upload } from "lucide-react"; -import { toast } from "sonner"; +import { Upload } from "lucide-react"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; -import { IntakeEditDialog } from "@/components/medications/intake-edit-dialog"; -import { - IntakeHistoryListV2, - type IntakeEvent, -} from "@/components/medications/intake-history-list-v2"; +import { IntakeHistoryEditable } from "@/components/medications/intake-history-editable"; import { IntakeImportDialog } from "@/components/medications/intake-import-dialog"; import { MedicationDetailSection } from "@/components/medications/medication-detail-section"; import { useTranslations } from "@/lib/i18n/context"; -import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys"; export interface IntakeHistoryPreviewProps { medicationId: string; @@ -61,91 +34,6 @@ export function IntakeHistoryPreview({ onImportOpenChange, }: IntakeHistoryPreviewProps) { const { t } = useTranslations(); - const queryClient = useQueryClient(); - - const [selected, setSelected] = useState>(new Set()); - const [editingEvent, setEditingEvent] = useState(null); - const [pendingDeleteId, setPendingDeleteId] = useState(null); - const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false); - const [bulkBusy, setBulkBusy] = useState(false); - const [rowDeleteBusy, setRowDeleteBusy] = useState(false); - - function onToggle(id: string) { - setSelected((prev) => { - const next = new Set(prev); - if (next.has(id)) { - next.delete(id); - } else { - next.add(id); - } - return next; - }); - } - - function clearSelection() { - setSelected(new Set()); - } - - async function confirmBulkDelete() { - if (bulkBusy || selected.size === 0) return; - setBulkBusy(true); - const ids = Array.from(selected); - try { - const res = await fetch( - `/api/medications/${medicationId}/intake/bulk-delete`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ eventIds: ids }), - }, - ); - if (!res.ok) { - toast.error(t("medications.detail.intake.bulkDelete.failed")); - return; - } - await invalidateKeys(queryClient, medicationDependentKeys); - toast.success(t("medications.detail.intake.bulkDelete.toast")); - setBulkConfirmOpen(false); - clearSelection(); - } catch { - toast.error(t("medications.detail.intake.bulkDelete.failed")); - } finally { - setBulkBusy(false); - } - } - - async function confirmRowDelete() { - if (rowDeleteBusy || !pendingDeleteId) return; - setRowDeleteBusy(true); - const id = pendingDeleteId; - try { - const res = await fetch( - `/api/medications/${medicationId}/intake/${id}`, - { - method: "DELETE", - }, - ); - if (!res.ok) { - toast.error(t("medications.detail.intake.deleteRow.failed")); - return; - } - await invalidateKeys(queryClient, medicationDependentKeys); - toast.success(t("medications.detail.intake.deleteRow.toast")); - setPendingDeleteId(null); - // Drop the deleted row from the pending selection so the - // bulk-toolbar count stays honest. - setSelected((prev) => { - if (!prev.has(id)) return prev; - const next = new Set(prev); - next.delete(id); - return next; - }); - } catch { - toast.error(t("medications.detail.intake.deleteRow.failed")); - } finally { - setRowDeleteBusy(false); - } - } return ( <> @@ -166,67 +54,7 @@ export function IntakeHistoryPreview({ } > -
- {selected.size > 0 && ( -
- - {t("medications.detail.intake.bulkDelete.selectionCount", { - count: selected.size, - })} - -
- - -
-
- )} - - -

- - {t("medications.detail.intake.viewAllLink")} - -

-
+
{importOpen && ( @@ -235,90 +63,6 @@ export function IntakeHistoryPreview({ onClose={() => onImportOpenChange(false)} /> )} - - setEditingEvent(null)} - /> - - { - if (!open) setBulkConfirmOpen(false); - }} - > - - - - {t("medications.detail.intake.bulkDelete.confirmTitle")} - - - {t("medications.detail.intake.bulkDelete.confirmBody", { - count: selected.size, - })} - - - - - {t("medications.detail.intake.bulkDelete.cancelButton")} - - { - e.preventDefault(); - void confirmBulkDelete(); - }} - disabled={bulkBusy} - aria-busy={bulkBusy || undefined} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" - > - {t("medications.detail.intake.bulkDelete.confirmAction")} - - - - - - { - if (!open) setPendingDeleteId(null); - }} - > - - - - {t("medications.detail.intake.deleteRow.confirmTitle")} - - - {t("medications.detail.intake.deleteRow.confirmBody")} - - - - - {t("medications.detail.intake.bulkDelete.cancelButton")} - - { - e.preventDefault(); - void confirmRowDelete(); - }} - disabled={rowDeleteBusy} - aria-busy={rowDeleteBusy || undefined} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" - > - {t("medications.detail.intake.deleteRow.confirmAction")} - - - - ); } diff --git a/src/components/medications/sections/notifications-section.tsx b/src/components/medications/sections/notifications-section.tsx index 628088075..d4a089453 100644 --- a/src/components/medications/sections/notifications-section.tsx +++ b/src/components/medications/sections/notifications-section.tsx @@ -68,7 +68,25 @@ const SECTION_TITLE_ID = "medication-detail-notifications-heading"; const ROW_TITLE_ID = "medication-detail-notifications-row-label"; const HELPER_ID = "medication-detail-notifications-helper"; -export function NotificationsSection({ +/** + * v1.7.0 — section wrapper. Keeps the `` + * chrome for any standalone consumer; the advanced-settings sheet + * consumes `` directly under its own group header. + */ +export function NotificationsSection(props: NotificationsSectionProps) { + const { t } = useTranslations(); + return ( + + + + ); +} + +export function NotificationsBody({ medicationId, notificationsEnabled, }: NotificationsSectionProps) { @@ -138,74 +156,64 @@ export function NotificationsSection({ } return ( - -
- {clientManaged ? ( - // v1.5.5 §9.6 — iPhone-managed path. The switch is hidden; - // the read-only chip tells the user where the toggle lives. -

- - {t("medications.detail.notifications.clientManagedChip")} - -

- ) : ( -
); } diff --git a/src/components/medications/sections/settings-section.tsx b/src/components/medications/sections/settings-section.tsx index 877c12f67..70036478c 100644 --- a/src/components/medications/sections/settings-section.tsx +++ b/src/components/medications/sections/settings-section.tsx @@ -1,18 +1,15 @@ "use client"; /** - * v1.5.5 D-3 §9.7 — Settings section. + * v1.5.5 D-3 §9.7 — Settings rows. * - * Hosts the four sub-rows the v1.5.4 form retirement displaced: - * Externe Integration (API tokens), CSV-Import stub (one-line pointer - * to the intake-history header CTA, no button), Phasen (button → - * ``, mounts only on GLP-1 + course window set), and - * Grace minutes (primary-schedule-scoped). - * - * Per H-3-UX: the CSV-Import row is a one-line stub. The actual - * import affordance lives next to the table it changes (the - * intake-history header), so the page mounts the dialog exactly once - * via lifted state. + * v1.7.0 — the monolithic "Settings" card is decomposed into reusable + * rows so the redesigned `` can slot each one + * under the right group: API tokens → Data, Grace → Reminders, + * Phasen → Lifecycle. The standalone `` wrapper stays + * (it composes the rows under one section card) so existing callers + + * tests keep working. The CSV-import stub is gone — the sheet's Data + * group carries a real import button. * * Per H-4-UX: the Grace row's label flags it as primary-schedule * scoped so the user knows a multi-schedule medication does not get a @@ -51,27 +48,24 @@ export interface SettingsSectionProps { onRequestPhaseSheet?: () => void; } -export function SettingsSection({ +/** + * v1.7.0 — primary-schedule reminder-window (grace) row. Self-saves via + * `PUT /api/medications/[id]` with a top-level `reminderGraceMinutes`. + */ +export function GraceRow({ medicationId, - medicationName, - treatmentClass, - startsOn, - endsOn, reminderGraceMinutes, - onRequestPhaseSheet, -}: SettingsSectionProps) { +}: { + medicationId: string; + reminderGraceMinutes?: number | null; +}) { const { t } = useTranslations(); const queryClient = useQueryClient(); - const [phaseSheetOpen, setPhaseSheetOpen] = useState(false); const [graceValue, setGraceValue] = useState( typeof reminderGraceMinutes === "number" ? reminderGraceMinutes : 30, ); const [graceBusy, setGraceBusy] = useState(false); - const isGlp1 = treatmentClass === "GLP1"; - const hasCourseWindow = Boolean(startsOn) && Boolean(endsOn); - const showPhases = isGlp1 && hasCourseWindow; - async function saveGrace() { if (graceBusy) return; setGraceBusy(true); @@ -99,115 +93,108 @@ export function SettingsSection({ } return ( - -
- {/* Externe Integration — API tokens */} - + +

+ {t("medications.detail.settings.grace.primaryScheduleNote")} +

+
+ setGraceValue(Number(e.target.value) || 0)} + className="w-24" /> - - - - {/* CSV-Import stub — H-3-UX */} -
+ {t("medications.detail.settings.grace.unit")} + + +
+
+ ); +} + +/** + * v1.7.0 — phases / course-window row. GLP-1 only; the button mounts + * once a course window is set, otherwise an italic hint. When + * `onRequestPhaseSheet` is provided the parent orchestrates the + * sibling-swap; otherwise the row self-mounts ``. + */ +export function PhasesRow({ + medicationId, + treatmentClass, + startsOn, + endsOn, + onRequestPhaseSheet, +}: { + medicationId: string; + treatmentClass?: string; + startsOn?: string | null; + endsOn?: string | null; + onRequestPhaseSheet?: () => void; +}) { + const { t } = useTranslations(); + const [phaseSheetOpen, setPhaseSheetOpen] = useState(false); + + const isGlp1 = treatmentClass === "GLP1"; + const hasCourseWindow = Boolean(startsOn) && Boolean(endsOn); + const showPhases = isGlp1 && hasCourseWindow; + + if (!isGlp1) return null; + + return ( + <> +
+

- {t("medications.detail.settings.csvImport.title")} + {t("medications.phaseConfig")}

- {t("medications.detail.settings.csvImport.stub")} + {t("medications.phaseConfigDescription")}

- - {/* Phasen — GLP-1 + course-window gated */} - {isGlp1 && ( - <> - -
-
-

- {t("medications.phaseConfig")} -

-

- {t("medications.phaseConfigDescription")} -

-
- {showPhases ? ( - - ) : ( -

- {t("medications.detail.settings.phases.requiresCourseWindow")} -

- )} -
- - )} - - - - {/* Grace minutes — primary-schedule scoped */} -
- -

- {t("medications.detail.settings.grace.primaryScheduleNote")} + {t("medications.detail.settings.phases.openButton")} + + ) : ( +

+ {t("medications.detail.settings.phases.requiresCourseWindow")}

-
- setGraceValue(Number(e.target.value) || 0)} - className="w-24" - /> - - {t("medications.detail.settings.grace.unit")} - - -
-
+ )}
{showPhases && !onRequestPhaseSheet && ( @@ -217,6 +204,58 @@ export function SettingsSection({ onOpenChange={setPhaseSheetOpen} /> )} + + ); +} + +/** + * Standalone section wrapper — composes the rows under one section + * card. Retained for callers / tests that expect the bundled shape. + */ +export function SettingsSection({ + medicationId, + medicationName, + treatmentClass, + startsOn, + endsOn, + reminderGraceMinutes, + onRequestPhaseSheet, +}: SettingsSectionProps) { + const { t } = useTranslations(); + const isGlp1 = treatmentClass === "GLP1"; + + return ( + +
+ + + {isGlp1 && ( + <> + + + + )} + + + + +
); } diff --git a/src/components/medications/settings-group.tsx b/src/components/medications/settings-group.tsx new file mode 100644 index 000000000..769682184 --- /dev/null +++ b/src/components/medications/settings-group.tsx @@ -0,0 +1,42 @@ +"use client"; + +/** + * v1.7.0 — advanced-settings group header. + * + * Lightweight presentational grouping for the redesigned + * ``: an uppercase micro-label over a hairline + * rule, then the group body. Reads as "one settings document, four + * parts" rather than three bordered section cards. + * + * The label is a plain `

` (not a heading with an id) so it stays out + * of the `aria-labelledby` graph — the sheet already owns the dialog + * title, and the inner switch labels carry their own ids. This avoids + * the duplicate-id axe failure the section-card heading split guards + * against. + */ + +import { type ReactNode } from "react"; + +export interface SettingsGroupProps { + label: ReactNode; + children: ReactNode; + dataSlot?: string; +} + +export function SettingsGroup({ + label, + children, + dataSlot, +}: SettingsGroupProps) { + return ( +

+

+ {label} +

+ {children} +
+ ); +} diff --git a/src/components/mood/mood-form.tsx b/src/components/mood/mood-form.tsx index ad26b5a51..cf7956f68 100644 --- a/src/components/mood/mood-form.tsx +++ b/src/components/mood/mood-form.tsx @@ -16,6 +16,7 @@ import { Label } from "@/components/ui/label"; import { Loader2, MoreHorizontal, Plus, RotateCcw } from "lucide-react"; import { toast } from "sonner"; import { useTranslations } from "@/lib/i18n/context"; +import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group"; import { invalidateKeys, moodDependentKeys } from "@/lib/query-keys"; const MOOD_LEVELS = [ @@ -70,6 +71,11 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) { const queryClient = useQueryClient(); const [mood, setMood] = useState(""); + const { getRadioProps: getMoodRadioProps } = useRovingRadioGroup({ + count: MOOD_LEVELS.length, + selectedIndex: MOOD_LEVELS.findIndex((l) => l.value === mood), + onSelect: (index) => setMood(MOOD_LEVELS[index]!.value), + }); const [tagsInput, setTagsInput] = useState(""); const [moodLoggedAt, setMoodLoggedAt] = useState(getDefaultMoodLoggedAtValue); const [loading, setLoading] = useState(false); @@ -191,7 +197,7 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) { aria-labelledby="mood-level-label" className="grid grid-cols-5 gap-2" > - {MOOD_LEVELS.map((level) => { + {MOOD_LEVELS.map((level, index) => { const isSelected = mood === level.value; return (
{(() => { - const sortedWidgets = [...layout.widgets].sort( - (a, b) => a.order - b.order, - ); + // v1.7.0 — the stored layout now round-trips the 11 iOS-only + // widget ids so the native client can drop its local merge + // workarounds. The web Settings list has no tile/chart + // surface for them, so skip any id outside the 16 web-known + // ids rather than render an unlabelled row with dead toggles. + // The skipped ids stay untouched in the persisted layout + // because the Save mutation PUTs `layout.widgets` whole and + // the server retains every catalogue id. + const webWidgetIds = new Set(DASHBOARD_WIDGET_IDS); + const sortedWidgets = [...layout.widgets] + .filter((w): w is typeof w & { id: DashboardWidgetId } => + webWidgetIds.has(w.id), + ) + .sort((a, b) => a.order - b.order); const sortedIds = sortedWidgets.map((w) => w.id); return ( + + ); diff --git a/src/components/settings/export-section.tsx b/src/components/settings/export-section.tsx index f775d1cf7..c77ed937c 100644 --- a/src/components/settings/export-section.tsx +++ b/src/components/settings/export-section.tsx @@ -41,6 +41,7 @@ import { DateInput } from "@/components/ui/date-input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { ArztberichtHeroCard } from "@/components/settings/arztbericht-hero-card"; +import { HealthRecordExportPanel } from "@/components/settings/health-record-export-panel"; import { useTranslations } from "@/lib/i18n/context"; type ExportFormat = "CSV" | "JSON"; @@ -91,6 +92,11 @@ export function ExportSection() { top of the page; the same generation flow runs underneath. */} + {/* v1.7.0 — full health-record export. PDF + FHIR R4 + a combined + zip package, with grouped data-section toggles and a format + switch. Sits above the secondary CSV/JSON cards. */} + + {/* v1.4.37 W7a — secondary export options. The doctor-report card moved into the hero above, so this group renders the four remaining destinations under a dedicated sub-heading. diff --git a/src/components/settings/health-record-export-panel.tsx b/src/components/settings/health-record-export-panel.tsx new file mode 100644 index 000000000..78be0dcfd --- /dev/null +++ b/src/components/settings/health-record-export-panel.tsx @@ -0,0 +1,420 @@ +"use client"; + +/** + * v1.7.0 — Settings → Export → Health Record export panel. + * + * One surface that drives `POST /api/export/health-record`: a format + * toggle (PDF / FHIR / package) that drives the visible options below it + * (per the "dropdown drives the form, no top/bottom split" preference), + * a date-range picker, grouped data-section toggles, and the generate + * action that streams the artefact as a download. + * + * Mood stays default-OFF everywhere (privacy). PDF-only fields (practice + * name, charts) hide when the format is FHIR. The ePA-compat note shows + * for the FHIR + package formats. + */ + +import { useState } from "react"; +import { Download, FileText, Loader2 } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { NativeSelect } from "@/components/ui/native-select"; +import { Switch } from "@/components/ui/switch"; +import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group"; +import { useTranslations } from "@/lib/i18n/context"; + +type ExportFormat = "pdf" | "fhir" | "package"; + +const EXPORT_FORMATS: readonly ExportFormat[] = ["pdf", "fhir", "package"]; + +interface SectionState { + weight: boolean; + bp: boolean; + pulse: boolean; + oxygenSaturation: boolean; + bodyFat: boolean; + bodyComposition: boolean; + restingHeartRate: boolean; + hrv: boolean; + vo2max: boolean; + steps: boolean; + distance: boolean; + sleep: boolean; + glucose: boolean; + medList: boolean; + compliance: boolean; + mood: boolean; + bmi: boolean; +} + +const DEFAULT_SECTIONS: SectionState = { + weight: true, + bp: true, + pulse: true, + oxygenSaturation: true, + bodyFat: true, + bodyComposition: false, + restingHeartRate: false, + hrv: false, + vo2max: false, + steps: false, + distance: false, + sleep: false, + glucose: true, + medList: true, + compliance: true, + mood: false, // privacy default + bmi: true, +}; + +function buildSelectionSections(s: SectionState) { + return { + vitals: { + weight: s.weight, + bp: s.bp, + pulse: s.pulse, + oxygenSaturation: s.oxygenSaturation, + bodyFat: s.bodyFat, + bodyComposition: s.bodyComposition, + }, + cardioFitness: { + restingHeartRate: s.restingHeartRate, + hrv: s.hrv, + vo2max: s.vo2max, + }, + activity: { + steps: s.steps, + distance: s.distance, + sleep: s.sleep, + }, + glucose: s.glucose, + medications: { list: s.medList, compliance: s.compliance }, + mood: s.mood, + bmi: s.bmi, + }; +} + +export function HealthRecordExportPanel() { + const { t } = useTranslations(); + const [format, setFormat] = useState("pdf"); + const [days, setDays] = useState(90); + const [practiceName, setPracticeName] = useState(""); + const [includeCharts, setIncludeCharts] = useState(true); + const [includeAiSummary, setIncludeAiSummary] = useState(false); + const [sections, setSections] = useState(DEFAULT_SECTIONS); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const isPdfLike = format === "pdf" || format === "package"; + const isFhirLike = format === "fhir" || format === "package"; + + const { getRadioProps: getFormatRadioProps } = useRovingRadioGroup({ + count: EXPORT_FORMATS.length, + selectedIndex: EXPORT_FORMATS.indexOf(format), + onSelect: (index) => setFormat(EXPORT_FORMATS[index]!), + }); + + function toggle(key: keyof SectionState) { + setSections((prev) => ({ ...prev, [key]: !prev[key] })); + } + + // The single "Medikamente" control gates the whole medication block + // (list + compliance) so unchecking it actually excludes all medication + // data from the export — matching the iOS five-section model. + function toggleMedications() { + setSections((prev) => { + const next = !(prev.medList && prev.compliance); + return { ...prev, medList: next, compliance: next }; + }); + } + + async function handleGenerate() { + setBusy(true); + setError(null); + try { + const res = await fetch("/api/export/health-record", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + format, + range: { days }, + practiceName: practiceName.trim() || undefined, + includeCharts, + includeAiSummary, + sections: buildSelectionSections(sections), + }), + }); + if (!res.ok) { + setError(`${res.status}`); + return; + } + const blob = await res.blob(); + const ext = + format === "pdf" ? "pdf" : format === "fhir" ? "json" : "zip"; + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `healthlog-health-record-${new Date() + .toISOString() + .slice(0, 10)}.${ext}`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + } + + return ( +
+
+
+

+ {t("settings.healthRecord.description")} +

+ +
+ {/* Format toggle */} +
+ + {t("settings.healthRecord.format")} + +
+ {EXPORT_FORMATS.map((f, index) => ( + + ))} +
+
+ + {/* Date range */} +
+ + setDays(Number(e.target.value))} + > + + + + +
+ + {/* Practice name — PDF only */} + {isPdfLike && ( +
+ + setPracticeName(e.target.value)} + maxLength={120} + placeholder={t("settings.healthRecord.practiceNamePlaceholder")} + /> +
+ )} + + {/* Section groups */} +
+ + {t("settings.healthRecord.includedData")} + + +
+

+ {t("settings.healthRecord.groupVitals")} +

+
+ toggle("weight")} + /> + toggle("bp")} + /> + toggle("pulse")} + /> + toggle("oxygenSaturation")} + /> + toggle("bodyFat")} + /> + toggle("bodyComposition")} + /> +
+
+ +
+

+ {t("settings.healthRecord.groupCardio")} +

+
+ toggle("restingHeartRate")} + /> + toggle("hrv")} + /> + toggle("vo2max")} + /> +
+
+ +
+

+ {t("settings.healthRecord.groupActivity")} +

+
+ toggle("steps")} + /> + toggle("distance")} + /> + toggle("sleep")} + /> +
+
+ +
+ toggle("glucose")} + /> + + toggle("bmi")} + /> + toggle("mood")} + /> +
+ + {isPdfLike && ( + setIncludeCharts((v) => !v)} + /> + )} + {isPdfLike && ( + setIncludeAiSummary((v) => !v)} + /> + )} +
+ + {isFhirLike && ( +

+ {t("settings.healthRecord.fhirNote")} +

+ )} + +
+ +
+ + {error && ( +

+ {t("settings.healthRecord.error", { code: error })} +

+ )} +
+
+ ); +} + +function ToggleRow({ + label, + checked, + onToggle, +}: { + label: string; + checked: boolean; + onToggle: () => void; +}) { + return ( + + ); +} diff --git a/src/components/settings/mood-reminder-card.tsx b/src/components/settings/mood-reminder-card.tsx index 89bb30031..7d1340400 100644 --- a/src/components/settings/mood-reminder-card.tsx +++ b/src/components/settings/mood-reminder-card.tsx @@ -13,6 +13,10 @@ interface ProfileShape { moodReminderEnabled: boolean; } +interface NotificationPrefsShape { + mood: { reminderHour: number }; +} + export function MoodReminderCard({ isAuthenticated, }: { @@ -21,6 +25,7 @@ export function MoodReminderCard({ const { t } = useTranslations(); const queryClient = useQueryClient(); const [optimistic, setOptimistic] = useState(null); + const [optimisticHour, setOptimisticHour] = useState(null); const [saving, setSaving] = useState(false); const [msg, setMsg] = useState(null); const [msgType, setMsgType] = useState<"success" | "error" | null>(null); @@ -66,7 +71,20 @@ export function MoodReminderCard({ enabled: isAuthenticated, }); + // v1.7.0 — the per-user reminder hour lives in the roaming + // notification-prefs blob so iOS + web share one source of truth. + const { data: prefs } = useQuery({ + queryKey: queryKeys.authNotificationPrefs(), + queryFn: async () => { + const res = await fetch("/api/auth/me/notification-prefs"); + if (!res.ok) throw new Error("Failed to load notification prefs"); + return (await res.json()).data as NotificationPrefsShape; + }, + enabled: isAuthenticated, + }); + const enabled = optimistic ?? profile?.moodReminderEnabled ?? false; + const reminderHour = optimisticHour ?? prefs?.mood.reminderHour ?? 22; async function handleToggle(next: boolean) { setOptimistic(next); @@ -103,6 +121,39 @@ export function MoodReminderCard({ setSaving(false); } + async function handleHourChange(next: number) { + setOptimisticHour(next); + setSaving(true); + setMsg(null); + setMsgType(null); + if (clearTimerRef.current !== null) { + clearTimeout(clearTimerRef.current); + clearTimerRef.current = null; + } + + const res = await fetch("/api/auth/me/notification-prefs", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mood: { reminderHour: next } }), + }); + + if (res.ok) { + setMsg(t("notifications.moodReminder.hourSavedToast")); + setMsgType("success"); + await queryClient.invalidateQueries({ + queryKey: queryKeys.authNotificationPrefs(), + }); + setOptimisticHour(null); + scheduleClear(); + } else { + setOptimisticHour(null); + setMsg(t("notifications.moodReminder.saveError")); + setMsgType("error"); + scheduleClear(); + } + setSaving(false); + } + return (
} /> + {enabled && ( +
+ + +
+ )} {msg && (

(null); React.useEffect(() => { if (typeof window === "undefined") return; @@ -163,13 +169,13 @@ export function SettingsShell({ active, children }: SettingsShellProps) { if (!strip) return; const active = strip.querySelector('[aria-current="page"]'); if (!active) return; - active.scrollIntoView({ - block: "nearest", - // v1.4.36 W4b — `inline: "start"` pins the active chip to the - // left edge of the scroller. `inline: "center"` over-scrolled - // and the first one or two chips were unreachable on narrow - // viewports. - inline: "start", + // Land the active chip at the strip's left edge, clamped to the + // scrollable range. `offsetLeft` is relative to the strip's padding + // box, which is the origin for `scrollLeft`. + const maxScroll = strip.scrollWidth - strip.clientWidth; + const target = Math.max(0, Math.min(active.offsetLeft, maxScroll)); + strip.scrollTo({ + left: target, // v1.4.43 W5-H5 — respect `prefers-reduced-motion`. behavior: scrollBehaviorForUser(), }); diff --git a/src/components/settings/unit-preference-card.tsx b/src/components/settings/unit-preference-card.tsx new file mode 100644 index 000000000..d825d8d45 --- /dev/null +++ b/src/components/settings/unit-preference-card.tsx @@ -0,0 +1,189 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Ruler } from "lucide-react"; + +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { cn } from "@/lib/utils"; +import { useAuth } from "@/hooks/use-auth"; +import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; + +type UnitPreference = "metric" | "imperial"; + +/** + * v1.7.0 — Settings → Display metric/imperial control. + * + * The W7 backend (`GET/PATCH /api/auth/me/unit-preference`) and the + * `useAuth().unitPreference` field shipped without a visible toggle. + * This card is the follow-up surface: a two-option segmented control + * that reads the current value from `useAuth()` and PATCHes the + * endpoint on change. The mutation invalidates `queryKeys.authMe()` + * so every chart display transform that keys off `unitPreference` + * (km/h vs mph, km vs mi) re-renders on the next /me refetch — no + * manual reload. + */ +export function UnitPreferenceCard({ + isAuthenticated, +}: { + isAuthenticated: boolean; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const { user } = useAuth(); + + const [optimistic, setOptimistic] = useState(null); + const [msg, setMsg] = useState(null); + const [msgType, setMsgType] = useState<"success" | "error" | null>(null); + + const clearTimerRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (clearTimerRef.current !== null) { + clearTimeout(clearTimerRef.current); + clearTimerRef.current = null; + } + }; + }, []); + + function scheduleClear() { + if (clearTimerRef.current !== null) { + clearTimeout(clearTimerRef.current); + } + clearTimerRef.current = setTimeout(() => { + clearTimerRef.current = null; + setMsg(null); + setMsgType(null); + }, 3000); + } + + const value: UnitPreference = optimistic ?? user?.unitPreference ?? "metric"; + + const mutation = useMutation({ + mutationFn: async (next: UnitPreference) => { + const res = await fetch("/api/auth/me/unit-preference", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ unitPreference: next }), + }); + if (!res.ok) { + const json = (await res.json().catch(() => ({}))) as { + error?: string; + }; + throw new Error(json.error ?? `HTTP ${res.status}`); + } + return next; + }, + onSuccess: () => { + setMsg(t("settings.dashboard.units.saved")); + setMsgType("success"); + // Refresh `useAuth().unitPreference` so the chart display + // transforms re-render on the next /api/auth/me refetch. + queryClient.invalidateQueries({ queryKey: queryKeys.authMe() }); + // Keep the dedicated read in sync for any future consumer. + queryClient.invalidateQueries({ + queryKey: queryKeys.userUnitPreference(), + }); + setOptimistic(null); + scheduleClear(); + }, + onError: (err) => { + setOptimistic(null); + setMsg( + err instanceof Error + ? err.message + : t("settings.dashboard.units.saveError"), + ); + setMsgType("error"); + scheduleClear(); + }, + }); + + function handleSelect(next: UnitPreference) { + if (next === value || mutation.isPending || !isAuthenticated) return; + setOptimistic(next); + setMsg(null); + setMsgType(null); + if (clearTimerRef.current !== null) { + clearTimeout(clearTimerRef.current); + clearTimerRef.current = null; + } + mutation.mutate(next); + } + + const options: ReadonlyArray<{ key: UnitPreference; label: string }> = [ + { key: "metric", label: t("settings.dashboard.units.metric") }, + { key: "imperial", label: t("settings.dashboard.units.imperial") }, + ]; + + const optionsDisabled = !isAuthenticated || mutation.isPending; + const { getRadioProps } = useRovingRadioGroup({ + count: options.length, + selectedIndex: options.findIndex((o) => o.key === value), + onSelect: (index) => handleSelect(options[index]!.key), + isDisabled: () => optionsDisabled, + }); + + return ( +

+ + {options.map((opt, index) => { + const selected = value === opt.key; + return ( + + ); + })} + + } + /> + {msg && ( +

+ {msg} +

+ )} +
+ ); +} diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx index 0f65eee9f..7f2cd19f0 100644 --- a/src/components/ui/avatar.tsx +++ b/src/components/ui/avatar.tsx @@ -32,7 +32,11 @@ function AvatarImage({ return ( ); diff --git a/src/hooks/__tests__/use-roving-radio-group.test.ts b/src/hooks/__tests__/use-roving-radio-group.test.ts new file mode 100644 index 000000000..97837a5c3 --- /dev/null +++ b/src/hooks/__tests__/use-roving-radio-group.test.ts @@ -0,0 +1,67 @@ +/** + * v1.7.0 — Roving-tabindex navigation math for ARIA radiogroups. + * + * The component test harness here is SSR-only (no jsdom / no simulated + * keyboard events), so the Arrow / Home / End logic that the hook layers + * onto the segmented controls is verified at the pure-function level. The + * render shape (tabindex 0 on the selected option, -1 on the rest, + * aria-checked) is pinned in each control's own SSR contract test. + */ +import { describe, expect, it } from "vitest"; + +import { rovingRadioNextIndex } from "../use-roving-radio-group"; + +const COUNT = 3; + +describe("rovingRadioNextIndex", () => { + it("ArrowRight / ArrowDown advance and wrap", () => { + expect(rovingRadioNextIndex("ArrowRight", 0, COUNT)).toBe(1); + expect(rovingRadioNextIndex("ArrowDown", 1, COUNT)).toBe(2); + expect(rovingRadioNextIndex("ArrowRight", 2, COUNT)).toBe(0); + }); + + it("ArrowLeft / ArrowUp retreat and wrap", () => { + expect(rovingRadioNextIndex("ArrowLeft", 2, COUNT)).toBe(1); + expect(rovingRadioNextIndex("ArrowUp", 1, COUNT)).toBe(0); + expect(rovingRadioNextIndex("ArrowLeft", 0, COUNT)).toBe(2); + }); + + it("Home / End jump to first / last", () => { + expect(rovingRadioNextIndex("Home", 2, COUNT)).toBe(0); + expect(rovingRadioNextIndex("End", 0, COUNT)).toBe(2); + }); + + it("returns null for non-navigation keys", () => { + expect(rovingRadioNextIndex("Enter", 0, COUNT)).toBeNull(); + expect(rovingRadioNextIndex(" ", 0, COUNT)).toBeNull(); + expect(rovingRadioNextIndex("Tab", 0, COUNT)).toBeNull(); + }); + + it("returns null when the group is empty", () => { + expect(rovingRadioNextIndex("ArrowRight", -1, 0)).toBeNull(); + }); + + it("starts from the first enabled option when nothing is selected", () => { + expect(rovingRadioNextIndex("ArrowRight", -1, COUNT)).toBe(1); + expect(rovingRadioNextIndex("ArrowLeft", -1, COUNT)).toBe(2); + }); + + it("skips disabled options when stepping", () => { + // Middle option disabled: ArrowRight from 0 lands on 2, not 1. + const middleDisabled = (i: number) => i === 1; + expect(rovingRadioNextIndex("ArrowRight", 0, COUNT, middleDisabled)).toBe(2); + expect(rovingRadioNextIndex("ArrowLeft", 2, COUNT, middleDisabled)).toBe(0); + }); + + it("Home / End respect disabled bounds", () => { + const endsDisabled = (i: number) => i === 0 || i === COUNT - 1; + expect(rovingRadioNextIndex("Home", 1, COUNT, endsDisabled)).toBe(1); + expect(rovingRadioNextIndex("End", 1, COUNT, endsDisabled)).toBe(1); + }); + + it("returns null when every option is disabled", () => { + expect( + rovingRadioNextIndex("ArrowRight", 0, COUNT, () => true), + ).toBeNull(); + }); +}); diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index a28b25e51..25156041f 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -29,6 +29,13 @@ export interface AuthUser { */ avatarUrl: string | null; glucoseUnit: string | null; + /** + * v1.7.0 — global metric/imperial display preference. Canonical + * storage stays SI; this selects the display-time transform branch + * (km/h vs mph, km vs mi). Null on a stale /me payload coerces to + * "metric" in `fetchMe`. + */ + unitPreference: "metric" | "imperial"; /** * v1.4.47 W3 — per-user Coach opt-out. When `true`, every Coach * mount point (``, ``, the @@ -39,6 +46,14 @@ export interface AuthUser { * stale /me payload from a partial-deploy rollback). */ disableCoach: boolean; + /** + * v1.7.0 — optional patient-identity fields used by the health-record + * export (PDF cover + FHIR Patient). All optional; `insuranceNumber` + * is the German KVNR, decrypted server-side for the form prefill. + */ + fullName: string | null; + insurerName: string | null; + insuranceNumber: string | null; } async function fetchMe(): Promise { @@ -57,6 +72,9 @@ async function fetchMe(): Promise { return { ...(data as AuthUser), disableCoach: data.disableCoach ?? false, + // v1.7.0 — coerce against a stale /me payload (older server image + // without the field) so the display defaults to metric. + unitPreference: data.unitPreference === "imperial" ? "imperial" : "metric", }; } diff --git a/src/hooks/use-roving-radio-group.ts b/src/hooks/use-roving-radio-group.ts new file mode 100644 index 000000000..ab9a90374 --- /dev/null +++ b/src/hooks/use-roving-radio-group.ts @@ -0,0 +1,170 @@ +"use client"; + +import { useCallback, useRef } from "react"; + +/** + * v1.7.0 — Roving-tabindex helper for hand-rolled ARIA radiogroups. + * + * Several controls render `role="radiogroup"` with `