diff --git a/.env.production.example b/.env.production.example index 2a3edb30a..55bcd326f 100644 --- a/.env.production.example +++ b/.env.production.example @@ -234,3 +234,16 @@ API_TOKEN_HMAC_KEY="" # Set to "1" to disable all third-party IP egress (GDPR Art. 44). With egress # off the resolver uses only the offline GeoLite2 MMDB tier, if present. # IP_GEO_LOOKUP_DISABLED="1" + +# ----------------------------------------------------------------------------- +# Environmental-context module (Open-Meteo) -- optional +# ----------------------------------------------------------------------------- +# The opt-in Environment module fetches daily weather / daylight for the user's +# coarse home (or a travel override) from Open-Meteo. The hosted endpoints are +# keyless and free for non-commercial use; data is CC BY 4.0. Both URLs are +# optional -- unset uses the hosted defaults below. A privacy-maximalist or +# commercial self-hoster can run their own Open-Meteo instance and point these +# at it (a private/LAN host is allowed -- the fetch is server-side and does NOT +# force the public-host guard, so an internal instance is not blocked). +# OPENMETEO_BASE_URL="https://archive-api.open-meteo.com" +# OPENMETEO_GEOCODING_URL="https://geocoding-api.open-meteo.com" diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 034378dc7..90dcfc9bd 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -60,7 +60,7 @@ jobs: - run: pnpm db:migrate:deploy - name: Cache Playwright browsers - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 5875d7df8..e8d2c8c15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,41 @@ ## [Unreleased] +## [1.25.0] — 2026-06-28 — Clinical depth + +A milestone release. It broadens tracking into clinical territory — validated mental-wellbeing self-assessments, grip strength, a pain score, waist measurements, a longevity lab panel, and respiratory rate as a first-class vital — adds structured allergy and family-history records, an optional weather-and-daylight context module, and the ability to file a doctor's letter and pull its facts into your record after you review them. It also makes the Coach's check-ins warmer, restores assistant-connector sign-in, and carries a broad correctness, performance, security, and accessibility pass. Additive migrations (`0208`–`0220`) apply automatically on start. + +### Added + +- Mental-wellbeing self-assessments (PHQ-9 and GAD-7) — opt-in, alongside your mood tracking and never a diagnosis. Item answers are encrypted; a non-zero self-harm response surfaces calm, locale-aware crisis-support contacts. Enable the Mental wellbeing module to use it. +- New signals you can log and chart like any other, each with its own detail page, reference range, and trend: grip strength, a 0–10 pain score, waist circumference and waist-to-height ratio, and respiratory rate. +- A longevity lab panel in the biomarker catalogue — ApoB, Lp(a), hs-CRP, HbA1c, fasting glucose and insulin, eGFR, GGT, ferritin, and the omega-3 index — with reference ranges. +- Structured medical-history records: allergies and family history, encrypted at rest, gathered under a new Medical history section and included in the health-record export. Immunizations are not part of this release. +- An optional environmental-context module: daily weather, daylight, and temperature for your location, correlated against mood, sleep, and vitals — off by default, with a home location, dated location periods, and backfill. +- Inbound documents: file a doctor's report or discharge letter and review the facts it contains before they enter your record. The app transcribes what is written and never interprets it; off by default. +- Biomarker detail pages now match the metric pages — description, summary, chart, trend, and a one-tap question to the Coach. + +### Changed + +- The Coach's proactive check-in is warmer and in your language: it greets you by name, keeps to one calm thought, never quotes your own words back, and never arrives two days running. A setting turns the daily suggestions off; another lets the Coach compose the check-in itself. +- Sleep debt now follows a configurable source order — a wearable's own figure or the computed one — and explains what the computed number means. +- One consistent assessment card, with a one-tap Coach question, across every insight page. +- Sortable history tables and real pagination where long lists used to truncate. +- A clear, non-diagnostic description of what HealthLog and the Coach are — and are not — and an explicit statement of where your data stays. + +### Fixed + +- Assistant connectors (such as Claude Code) can sign in again over the Model Context Protocol — an outbound-connection fix that also hardens every outbound request aimed at a host you configure. +- The daily briefing, health score, and weekly review keep their last good text instead of going blank when a model is slow. +- The clinician-share page renders again for newly created share links. +- Wearable daily figures, paused-medication adherence, and several long-window calculations are corrected. + +### Security + +- Free-text on the new records and on filed documents is encrypted at rest, and key rotation covers it. +- Mental-wellbeing answers stay out of the assistant connector, the Coach, and exports by default; their scores export only once you enable the module. +- A fresh second-factor check is required when changing the password. + ## [1.24.0] — 2026-06-28 — Model Context Protocol server A connector release. HealthLog can expose your own health record to MCP-compatible assistants (Claude, ChatGPT, and others) over a standard, OAuth-secured Model Context Protocol server — off by default, behind a module switch and a connector token you mint yourself. Two additive migrations (`0206`, `0207`) apply automatically on start. diff --git a/README.md b/README.md index f0f168074..1a6994b5f 100644 --- a/README.md +++ b/README.md @@ -28,14 +28,19 @@ HealthLog is a self-hosted health tracking app (PWA + native iOS client) that runs from a single `docker compose up` on a NAS, homelab, or small VPS. It brings weight, blood pressure, pulse, blood glucose, body composition, sleep, mood, menstrual cycle, and medications onto one timeline — synced from the devices you already own — and keeps everything encrypted at rest on hardware you control. No vendor cloud, no subscription, no telemetry. +HealthLog is a personal health record-keeping and wellness tool. It is not a medical device and does not diagnose, treat, cure, or prevent any disease — it helps you log, visualise, and export your own data, and leaves clinical judgement to a clinician. + Try the [live demo](https://demo.healthlog.dev) to see a working install — sign in with `demo` / `demo123demo123`. ## Highlights -- **Every vital on one timeline.** Weight, blood pressure, pulse, glucose, body composition, sleep, SpO₂, mood, and cycle tracking — charted with trends, moving averages, and clinical reference ranges (ESH 2023, ADA 2024), overridable with the targets your clinician set. +- **Every vital on one timeline.** Weight, blood pressure, pulse, glucose, body composition, sleep, SpO₂, respiratory rate, mood, and cycle tracking — charted with trends, moving averages, and clinical reference ranges (ESH 2023, ADA 2024), overridable with the targets your clinician set. +- **Clinical signals, opt-in and additive.** Validated mental-wellbeing self-checks (PHQ-9, GAD-7) that sit beside mood tracking with calm crisis-resource handling, grip strength, a 0–10 pain score, waist and body measurements, and a longevity lab panel — each added through one signal registry, so a new signal wires into charts, correlations, the coach, the FHIR map, and the API at once. +- **Records and the documents you're handed.** Structured allergies and family history (encrypted), lab values you key in or scan, and inbound documents — discharge letters and reports — read to extract the facts they state (values, codes, dates) for you to review and confirm. HealthLog transcribes what a document says; it never interprets or diagnoses from it. (Immunization records are deliberately out of scope.) +- **Context that explains the numbers.** An opt-in environmental module folds weather, daylight, and temperature in as influence signals (winter darkness against mood, heat against blood pressure) using a home location you set, so a pattern has somewhere to point. - **Your devices, one server.** Withings, WHOOP, Oura and Polar sync over OAuth2, Fitbit connects experimentally over the Fitbit Web API, an Apple Health `export.zip` folds your full history in, and the native iOS app streams HealthKit live. A per-metric source priority decides which reading is canonical when wearables overlap. - **Medication tracking that tells the truth.** Flexible schedules (weekly injections, weekday-only, intervals, PRN, cyclic), a configurable intake window per dose, and a traceable dose history where every slot reads taken, late, skipped, or missed — the same ledger the compliance rate is computed from, so the percentage can never disagree with the timeline. -- **AI insights you own.** Daily briefing, health scores, correlations, and a conversational coach grounded in your own measurements, with proactive check-ins. Bring your own OpenAI or Anthropic key, or point at a local endpoint (Ollama, LM Studio, vLLM) so nothing leaves your network. +- **AI insights you own.** Daily briefing, health scores, correlations, and a conversational coach grounded in your own measurements, with calm proactive check-ins it suppresses when there's nothing genuine to say. The coach describes your data, never diagnoses. Bring your own OpenAI or Anthropic key, or point at a local endpoint (Ollama, LM Studio, vLLM) so nothing leaves your network. - **Clinician-ready output.** A doctor-report PDF generated client-side, a read-only HL7 FHIR R4 API, and scoped, time-limited share links you revoke after the visit. - **Private by construction.** AES-256-GCM encryption at rest with zero-downtime key rotation, passkey login, server-side sessions, strict CSP — and no third-party tracking anywhere. - **Built to be lived in.** Installable PWA with offline support, medication reminders over APNs, Telegram, ntfy, and Web Push, a sub-second dashboard on years of imported history, English and German end to end. diff --git a/docs/api/mcp-capabilities.md b/docs/api/mcp-capabilities.md index 8c0b6dd8e..e467969bf 100644 --- a/docs/api/mcp-capabilities.md +++ b/docs/api/mcp-capabilities.md @@ -28,7 +28,10 @@ them up front): 4. **Data and context only.** The server ships facts; it never returns a diagnosis, clinical verdict, risk score, or treatment change. The assistant narrates the facts and leaves clinical judgement to the - user's clinician. + user's clinician. This mirrors HealthLog's project-wide self-description + standard: the app is a personal health record-keeping and wellness tool, + not a medical device, and nothing it surfaces — here or in the UI — + diagnoses or treats. Discover before fetching: call `list_metrics` (or read the `healthlog://measurements/inventory` resource) first to see what exists, @@ -95,6 +98,44 @@ Every read tool is annotated read-only / non-destructive / idempotent / closed-world — the read-only guarantee is structural (only read tools are registered for a read session), and the annotation merely advertises it. +### Clinical signals + +The v1.25 clinical-signals set — **grip strength**, **pain (0–10 NRS)**, +**waist circumference**, and **waist-to-height ratio** — is reachable +through the rollup-backed reads `get_metric_baseline`, `compare_metric`, +and `detect_changepoints` (pass the signal name, e.g. `grip strength` or +`waist-to-height ratio`), and through `search` → `fetch` (`search` +surfaces a signal only when the user has recorded it; `fetch metric:` +hydrates it). Each carries its unit and population reference band from the +single signal registry; absence is the usual `{ present: false }`. They +sit off the Coach data inventory by design, so `list_metrics` / +`get_metric_series` do not enumerate them — use the reads above. A new +signal is declared once in the in-app signal registry and shows up across +the metric tools, correlations, and the FHIR map together, so the MCP +surface never advertises a signal the record can't return. + +The **longevity lab panel** (ApoB, Lp(a), hs-CRP, HbA1c, fasting glucose, +fasting insulin, eGFR, GGT, ferritin, omega-3 index) is reached like any +other biomarker: `get_labs` (latest per analyte or, with `history:true`, a +paginated trajectory), the `healthlog://lab/{analyte}` resource template, +and `search` → `fetch` (`lab:`). Reference bounds come from the +curated biomarker catalogue. + +**Off the MCP surface by construction.** The PHQ-9 / GAD-7 mental-health +screeners (item content and totals) are excluded from AI / MCP, and the +environmental-context signals (`ENV_*`: temperature, sunshine, daylight, +precipitation, barometric pressure) are off MCP in v1. The structured +records (allergies, family history) and the inbound-document store are +deliberately not exposed either: the document pipeline extracts facts for +the user to confirm in-app rather than streaming them to a model. None of +them resolve through any read tool — they are not on the MCP exposure +allowlist. + +This mirrors HealthLog's project-wide self-description standard: the MCP +surface ships data and context, never a diagnosis, clinical verdict, or +treatment change — the assistant narrates the facts and leaves clinical +judgement to the user's clinician. + ## Resources Read-only context the assistant can attach alongside tool results. Like diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index afefa5a85..5697416df 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.24.0 + version: 1.25.0 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -42,6 +42,8 @@ tags: - name: MeasurementReminders - name: Labs - name: Illness + - name: Documents + - name: Records - name: Admin - name: Meta paths: @@ -2087,6 +2089,12 @@ paths: - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE + - PHQ9_SCORE + - GAD7_SCORE + - GRIP_STRENGTH + - PAIN_NRS + - WAIST_CIRCUMFERENCE + - WAIST_TO_HEIGHT - in: query name: from schema: @@ -2276,6 +2284,12 @@ paths: - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE + - PHQ9_SCORE + - GAD7_SCORE + - GRIP_STRENGTH + - PAIN_NRS + - WAIST_CIRCUMFERENCE + - WAIST_TO_HEIGHT value: type: number measuredAt: @@ -2350,8 +2364,10 @@ paths: tags: - Measurements summary: Delete measurements by external ID (iOS deletion-sync) - description: Removes the user's measurement rows whose externalId is in the request list. Rows owned by another user are - silently skipped (cross-user 404 guard). Up to 500 externalIds per call. + description: "Removes the user's HealthKit-ingested measurement rows (source `APPLE_HEALTH`) whose externalId is in the + request list. Only app-minted rows are eligible: an externalId that maps to an integration- or manually-sourced + row (a colliding externalUUID) is a no-op, never a delete. Rows owned by another user are silently skipped + (cross-user 404 guard). Up to 500 externalIds per call." requestBody: required: true content: @@ -4084,6 +4100,24 @@ paths: - OURA - TELEGRAM - MCP + sleepDebt: + maxItems: 8 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP metricPriority: type: object properties: @@ -4411,6 +4445,24 @@ paths: - OURA - TELEGRAM - MCP + sleepDebt: + maxItems: 8 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP deviceTypePriority: type: object properties: @@ -5770,6 +5822,12 @@ paths: - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE + - PHQ9_SCORE + - GAD7_SCORE + - GRIP_STRENGTH + - PAIN_NRS + - WAIST_CIRCUMFERENCE + - WAIST_TO_HEIGHT description: "The measurement type to read (single metric — no fan-out). Closed enum: an unknown type 422s." required: true description: "The measurement type to read (single metric — no fan-out). Closed enum: an unknown type 422s." @@ -6028,6 +6086,10 @@ paths: - AVERAGE_HEART_RATE - MAX_HEART_RATE - ENERGY_EXPENDITURE_KJ + - GRIP_STRENGTH + - PAIN_NRS + - WAIST_CIRCUMFERENCE + - WAIST_TO_HEIGHT description: "HealthKit metric id to assess (e.g. RESTING_HEART_RATE, SLEEP_DURATION). Closed enum: an unknown id 422s. The seven specialised metrics are served by their own routes and are not accepted here." required: true @@ -6052,6 +6114,47 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/insights/biomarker-assessment: + get: + tags: + - Insights + summary: Per-biomarker assessment + description: "Data-driven plain-language assessment for one user-scoped lab biomarker, reading its `LabResult` history. + Identical envelope to the metric-status card so the `InsightStatusCard` consumes it unchanged. Read-only: a + cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate); the + assessment regenerates only when a new reading lands. A marker with no numeric readings returns `insufficient` + without an LLM call. Auth via cookie or Bearer." + parameters: + - in: query + name: biomarkerId + schema: + type: string + minLength: 1 + maxLength: 64 + description: User-scoped biomarker id to assess. A cross-user or unknown id returns an `insufficient` envelope, not a + 404 (existence sealed). + required: true + description: User-scoped biomarker id to assess. A cross-user or unknown id returns an `insufficient` envelope, not a + 404 (existence sealed). + - in: query + name: locale + schema: + description: Optional UI-locale override; defaults to the session locale. + type: string + enum: + - de + - en + description: Optional UI-locale override; defaults to the session locale. + responses: + "200": + description: Assessment envelope (fresh, cached, or preparing). + content: + application/json: + schema: + $ref: "#/components/schemas/BiomarkerAssessmentResponseEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 /api/insights/derived: get: tags: @@ -6178,6 +6281,60 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/insights/health-status: + get: + tags: + - Insights + summary: Baseline-drift health status + description: Surfaces vitals drifting from the user's personal normal — out-of-band deviations from the + personal-baseline engine plus dated, sustained level shifts from the changepoint detector. Pure compute over the + rollup tier; awareness only, never a diagnosis. Requires the insights module. Cookie or Bearer auth. + responses: + "200": + description: The drift summary. + content: + application/json: + schema: + $ref: "#/components/schemas/InsightsHealthStatusEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + /api/insights/breathing-screening: + get: + tags: + - Insights + summary: Sleep-breathing screening signal + description: Summarises the last ~30 nights of the per-night sleep-breathing-disturbance index plus device-flagged + events into a calm awareness read. A screening signal only — never a HealthLog diagnosis. Requires the insights + module. Cookie or Bearer auth. + responses: + "200": + description: The breathing-screening summary. + content: + application/json: + schema: + $ref: "#/components/schemas/InsightsBreathingScreeningEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + /api/insights/labs-changes: + get: + tags: + - Insights + summary: What changed since your last lab panel + description: Pairs the two most-recent numeric lab panels and reports each shared analyte's signed delta plus its + reference-band standing. Absent when there are fewer than two panels or no analyte is shared. Neutral framing, + never a diagnosis. Cookie or Bearer auth. + responses: + "200": + description: The lab-change summary. + content: + application/json: + schema: + $ref: "#/components/schemas/InsightsLabsChangesEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 /api/mood/tags: get: tags: @@ -6906,8 +7063,10 @@ paths: tags: - Labs summary: Record a lab result - description: Creates a single lab result for the caller. `source` is hardcoded MANUAL on this path. The optional note is - AES-256-GCM encrypted before write. Audits as `labResult.create`. + description: Creates a single lab result for the caller. The optional `source` marks provenance (`"MANUAL"` | `"OCR"`); + it defaults to `"MANUAL"` when omitted, so the legacy hand-entry contract is unchanged. The on-device-OCR client + posts `"OCR"` after the human confirms the parsed values — the raw image never touches the server on this path. + The optional note is AES-256-GCM encrypted before write. Audits as `labResult.create`. requestBody: required: true content: @@ -6954,6 +7113,11 @@ paths: maxLength: 2000 - type: string const: "" + source: + type: string + enum: + - MANUAL + - OCR required: - takenAt responses: @@ -7897,6 +8061,861 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/mental-health/assessments: + get: + tags: + - Mental health + summary: List the caller's screener history + description: Returns PHQ-9 / GAD-7 administrations (newest first). Totals + bands + flags only; raw item answers are + never returned. + parameters: + - in: query + name: instrument + schema: + type: string + enum: + - PHQ9 + - GAD7 + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 200 + - in: query + name: offset + schema: + type: integer + minimum: 0 + maximum: 9007199254740991 + responses: + "200": + description: Screener history. + content: + application/json: + schema: + $ref: "#/components/schemas/ListMentalHealthAssessmentsEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + post: + tags: + - Mental health + summary: Record a completed PHQ-9 / GAD-7 screener + description: Stores one administration (item answers encrypted) and writes the derived total as a PHQ9_SCORE / + GAD7_SCORE measurement. On a positive PHQ-9 item-9 the response carries the locale-aware crisis-resource set. + requestBody: + content: + application/json: + schema: + type: object + properties: + instrument: + type: string + enum: + - PHQ9 + - GAD7 + items: + minItems: 1 + maxItems: 12 + type: array + items: + type: integer + minimum: 0 + maximum: 3 + functionalDifficulty: + type: integer + minimum: 0 + maximum: 3 + takenAt: + 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))$ + tz: + type: string + maxLength: 64 + locale: + type: string + maxLength: 16 + source: + default: WEB + type: string + enum: + - WEB + - IOS + externalId: + type: string + minLength: 1 + maxLength: 120 + required: + - instrument + - items + responses: + "201": + description: Assessment recorded. + content: + application/json: + schema: + $ref: "#/components/schemas/CreateMentalHealthAssessmentEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + /api/allergies: + get: + tags: + - Records + summary: List allergies (v1.25) + description: Returns the caller's live (non-deleted) allergy/intolerance records, newest-first. `includeInactive=false` + returns only ACTIVE records. + parameters: + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 200 + - in: query + name: includeInactive + schema: + anyOf: + - type: string + const: "true" + - type: string + const: "false" + responses: + "200": + description: The caller's allergy records. + content: + application/json: + schema: + $ref: "#/components/schemas/ListAllergiesEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + post: + tags: + - Records + summary: Record an allergy (v1.25) + description: Creates one allergy/intolerance record for the caller. The free-text reaction + note are AES-256-GCM + encrypted before write. Audits as `allergy.create`. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + substance: + type: string + minLength: 1 + maxLength: 160 + category: + default: OTHER + type: string + enum: + - FOOD + - MEDICATION + - ENVIRONMENT + - BIOLOGIC + - OTHER + type: + default: ALLERGY + type: string + enum: + - ALLERGY + - INTOLERANCE + severity: + anyOf: + - type: string + enum: + - MILD + - MODERATE + - SEVERE + - type: "null" + status: + default: ACTIVE + type: string + enum: + - ACTIVE + - INACTIVE + - RESOLVED + onsetAt: + 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" + reaction: + anyOf: + - type: string + maxLength: 2000 + - type: "null" + note: + anyOf: + - type: string + maxLength: 2000 + - type: "null" + required: + - substance + responses: + "201": + description: Allergy created. + content: + application/json: + schema: + $ref: "#/components/schemas/CreateAllergyEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + /api/allergies/{id}: + get: + tags: + - Records + summary: Read a single allergy (v1.25) + description: Returns the record including its decrypted `reaction` + `note`. Owner-scoped; a cross-user or tombstoned id 404s. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: The allergy record. + content: + application/json: + schema: + $ref: "#/components/schemas/GetAllergyEnvelope" + "401": *a1 + "404": &a12 + description: Allergy not found (or owned by another user). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + patch: + tags: + - Records + summary: Edit an allergy (v1.25) + description: Partial edit; omitted fields are left untouched. Audits as `allergy.update`. Owner-scoped. + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + substance: + type: string + minLength: 1 + maxLength: 160 + category: + type: string + enum: + - FOOD + - MEDICATION + - ENVIRONMENT + - BIOLOGIC + - OTHER + type: + type: string + enum: + - ALLERGY + - INTOLERANCE + severity: + anyOf: + - type: string + enum: + - MILD + - MODERATE + - SEVERE + - type: "null" + status: + type: string + enum: + - ACTIVE + - INACTIVE + - RESOLVED + onsetAt: + 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" + reaction: + anyOf: + - type: string + maxLength: 2000 + - type: "null" + note: + anyOf: + - type: string + maxLength: 2000 + - type: "null" + additionalProperties: false + responses: + "200": + description: Allergy updated. + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateAllergyEnvelope" + "401": *a1 + "404": *a12 + "422": *a3 + "429": *a4 + delete: + tags: + - Records + summary: Soft-delete an allergy (v1.25) + description: "Stamps `deletedAt` (tombstone). Idempotent — a re-delete is a no-op. Returns the `{ deleted: true }` + envelope. Audits as `allergy.delete`. Owner-scoped." + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: Soft-deleted. + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteAllergyEnvelope" + "401": *a1 + "404": *a12 + "422": *a3 + "429": *a4 + /api/family-history: + get: + tags: + - Records + summary: List family history (v1.25) + description: Returns the caller's live (non-deleted) family-history entries, newest-first. + parameters: + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 200 + responses: + "200": + description: The caller's family-history entries. + content: + application/json: + schema: + $ref: "#/components/schemas/ListFamilyHistoryEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + post: + tags: + - Records + summary: Record a family-history entry (v1.25) + description: Creates one condition-by-relative record for the caller. The free-text note is AES-256-GCM encrypted before + write. Audits as `family-history.create`. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + relationship: + type: string + enum: + - MOTHER + - FATHER + - SISTER + - BROTHER + - DAUGHTER + - SON + - GRANDMOTHER_MATERNAL + - GRANDFATHER_MATERNAL + - GRANDMOTHER_PATERNAL + - GRANDFATHER_PATERNAL + - AUNT + - UNCLE + - COUSIN + - HALF_SIBLING + - OTHER + condition: + type: string + minLength: 1 + maxLength: 160 + ageAtOnset: + anyOf: + - type: integer + minimum: 0 + maximum: 120 + - type: "null" + note: + anyOf: + - type: string + maxLength: 2000 + - type: "null" + required: + - relationship + - condition + responses: + "201": + description: Entry created. + content: + application/json: + schema: + $ref: "#/components/schemas/CreateFamilyHistoryEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + /api/family-history/{id}: + get: + tags: + - Records + summary: Read a single family-history entry (v1.25) + description: Returns the entry including its decrypted `note`. Owner-scoped; a cross-user or tombstoned id 404s. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: The family-history entry. + content: + application/json: + schema: + $ref: "#/components/schemas/GetFamilyHistoryEnvelope" + "401": *a1 + "404": &a13 + description: Family history entry not found (or owned by another user). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + patch: + tags: + - Records + summary: Edit a family-history entry (v1.25) + description: Partial edit; omitted fields are left untouched. Audits as `family-history.update`. Owner-scoped. + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + relationship: + type: string + enum: + - MOTHER + - FATHER + - SISTER + - BROTHER + - DAUGHTER + - SON + - GRANDMOTHER_MATERNAL + - GRANDFATHER_MATERNAL + - GRANDMOTHER_PATERNAL + - GRANDFATHER_PATERNAL + - AUNT + - UNCLE + - COUSIN + - HALF_SIBLING + - OTHER + condition: + type: string + minLength: 1 + maxLength: 160 + ageAtOnset: + anyOf: + - type: integer + minimum: 0 + maximum: 120 + - type: "null" + note: + anyOf: + - type: string + maxLength: 2000 + - type: "null" + additionalProperties: false + responses: + "200": + description: Entry updated. + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateFamilyHistoryEnvelope" + "401": *a1 + "404": *a13 + "422": *a3 + "429": *a4 + delete: + tags: + - Records + summary: Soft-delete a family-history entry (v1.25) + description: "Stamps `deletedAt` (tombstone). Idempotent — a re-delete is a no-op. Returns the `{ deleted: true }` + envelope. Audits as `family-history.delete`. Owner-scoped." + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: Soft-deleted. + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteFamilyHistoryEnvelope" + "401": *a1 + "404": *a13 + "422": *a3 + "429": *a4 + /api/documents/inbound: + get: + tags: + - Documents + summary: List inbound clinical documents + description: The caller's uploaded doctor reports / discharge letters (newest first, live only). Owner-scoped; gated on + the opt-in `inboundDocuments` module. + responses: + "200": + description: The document list. + content: + application/json: + schema: + $ref: "#/components/schemas/InboundDocumentListEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + post: + tags: + - Documents + summary: Upload + extract a clinical document + description: 'Ingests a doctor report / discharge letter through the dedicated OCR/vision provider, stores the raw + document ENCRYPTED at rest, and stages the extracted STRUCTURED FACTS for review. Two modes by content-type. + VISION (`multipart/form-data`): a `file` (JPEG/PNG/WebP, or PDF on an Anthropic vision provider; ≤ 12 MiB) plus + an optional `kind`. TEXT (`application/json`, opt-in local OCR): `{ mode: "text", text, kind? }` — only the + extracted text reaches the server. Both modes are AI-consent / rate / budget gated. Extraction reproduces what + the document states (stated codes/status only) — it never interprets. Nothing reaches the structured stores + here; the confirm route is the only write path.' + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + description: The clinical-document image or PDF. Validated by magic-byte MIME sniff, not the wire Content-Type. + kind: + description: Optional document-type label. + type: string + enum: + - DOCTOR_REPORT + - DISCHARGE_LETTER + - OTHER + required: + - file + application/json: + schema: + $ref: "#/components/schemas/InboundTextUploadRequest" + responses: + "201": + description: The created document + staging summary. + content: + application/json: + schema: + $ref: "#/components/schemas/InboundDocumentEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + /api/documents/inbound/{id}: + get: + tags: + - Documents + summary: Get a document + its staged facts + description: The document plus every staged fact for the review screen. Owner-scoped. + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Document detail. + content: + application/json: + schema: + $ref: "#/components/schemas/InboundDocumentDetailEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + delete: + tags: + - Documents + summary: Discard an inbound document + description: Soft-deletes the document + its staging. Facts already approved into the structured stores are independent + rows and are NOT affected. + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Discarded. + content: + application/json: + schema: + $ref: "#/components/schemas/InboundDiscardEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + /api/documents/inbound/{id}/facts/{factId}: + patch: + tags: + - Documents + summary: Edit a staged fact before approval + description: Corrects OCR / units / dates / codes on a pending fact. Clears `needsReview` (the values become + user-asserted). The fact's resource type cannot change. + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: factId + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + oneOf: + - type: object + properties: + factType: + type: string + const: CONDITION + label: + type: string + minLength: 1 + maxLength: 300 + code: + anyOf: + - type: string + maxLength: 64 + - type: "null" + codeSystem: + anyOf: + - type: string + enum: + - SNOMED + - ICD10 + - LOINC + - RXNORM + - ATC + - type: "null" + clinicalStatus: + anyOf: + - type: string + maxLength: 64 + - type: "null" + verificationStatus: + anyOf: + - type: string + maxLength: 64 + - type: "null" + onsetDate: + anyOf: + - type: string + pattern: ^\d{4}-\d{2}-\d{2}$ + - type: "null" + required: + - factType + - label + - type: object + properties: + factType: + type: string + const: OBSERVATION + label: + type: string + minLength: 1 + maxLength: 300 + code: + anyOf: + - type: string + maxLength: 64 + - type: "null" + codeSystem: + anyOf: + - type: string + enum: + - SNOMED + - ICD10 + - LOINC + - RXNORM + - ATC + - type: "null" + value: + anyOf: + - type: number + - type: "null" + valueText: + anyOf: + - type: string + maxLength: 300 + - type: "null" + unit: + anyOf: + - type: string + maxLength: 80 + - type: "null" + referenceLow: + anyOf: + - type: number + - type: "null" + referenceHigh: + anyOf: + - type: number + - type: "null" + effectiveDate: + anyOf: + - type: string + pattern: ^\d{4}-\d{2}-\d{2}$ + - type: "null" + required: + - factType + - label + - type: object + properties: + factType: + type: string + const: MEDICATION_STATEMENT + name: + type: string + minLength: 1 + maxLength: 200 + dose: + anyOf: + - type: string + maxLength: 120 + - type: "null" + rxNormCode: + anyOf: + - type: string + maxLength: 20 + - type: "null" + atcCode: + anyOf: + - type: string + maxLength: 16 + - type: "null" + statusStated: + anyOf: + - type: string + maxLength: 64 + - type: "null" + effectiveDate: + anyOf: + - type: string + pattern: ^\d{4}-\d{2}-\d{2}$ + - type: "null" + required: + - factType + - name + type: object + responses: + "200": + description: The updated fact. + content: + application/json: + schema: + $ref: "#/components/schemas/ExtractedFactEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + /api/documents/inbound/{id}/confirm: + post: + tags: + - Documents + summary: Confirm the review decisions + description: The ONLY write path out of staging. Commits the user's approvals into the structured stores (labs / + conditions / medications) and discards rejections. A low-confidence fact that was never edited cannot be + approved (fail-closed). Idempotent (Idempotency-Key). Audits as `documents.inbound.confirm`. + parameters: + - name: id + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + decisions: + minItems: 1 + maxItems: 120 + type: array + items: + type: object + properties: + factId: + type: string + minLength: 1 + maxLength: 40 + action: + type: string + enum: + - approve + - reject + required: + - factId + - action + required: + - decisions + responses: + "200": + description: Per-decision outcome. + content: + application/json: + schema: + $ref: "#/components/schemas/InboundConfirmEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 /api/onboarding/disclaimer: post: tags: @@ -9067,8 +10086,14 @@ components: type: boolean doctorReport: type: boolean + environment: + type: boolean mcp: type: boolean + inboundDocuments: + type: boolean + mentalHealth: + type: boolean additionalProperties: false description: Partial module enable/disable update. Each key is an optional boolean; an omitted key is left untouched, `false` disables the module, `true` (re-)enables it. Only the toggleable "secondary domains" are accepted — a @@ -9210,6 +10235,10 @@ components: enum: - weekly - biweekly + ambientSuggestions: + type: boolean + nudgeAiComposed: + type: boolean measurementReminder: type: object properties: @@ -9245,6 +10274,9 @@ components: - cycle-summary - signals - rhythm-events + - health-status + - breathing + - labs-changes visible: type: boolean order: @@ -9257,7 +10289,7 @@ components: - order tiles: minItems: 1 - maxItems: 50 + maxItems: 61 type: array items: type: object @@ -9271,6 +10303,7 @@ components: - oxygen - body-temperature - respiratory-rate + - pain - weight - bmi - body-water @@ -9280,6 +10313,8 @@ components: - muscle-mass - visceral-fat - lean-body-mass + - waist + - waist-to-height - steps - active-energy - workouts @@ -9296,6 +10331,7 @@ components: - six-minute-walk - stair-ascent-speed - stair-descent-speed + - grip-strength - sleep - breathing-disturbances - resting-pulse @@ -9593,6 +10629,23 @@ components: required: - mode - text + InboundTextUploadRequest: + type: object + properties: + mode: + type: string + const: text + text: + type: string + kind: + type: string + enum: + - DOCTOR_REPORT + - DISCHARGE_LETTER + - OTHER + required: + - mode + - text DisclaimerAckRequest: type: object properties: @@ -12255,6 +13308,12 @@ components: - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE + - PHQ9_SCORE + - GAD7_SCORE + - GRIP_STRENGTH + - PAIN_NRS + - WAIST_CIRCUMFERENCE + - WAIST_TO_HEIGHT value: type: number unit: @@ -12571,6 +13630,12 @@ components: - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE + - PHQ9_SCORE + - GAD7_SCORE + - GRIP_STRENGTH + - PAIN_NRS + - WAIST_CIRCUMFERENCE + - WAIST_TO_HEIGHT additionalProperties: type: array items: @@ -12648,6 +13713,12 @@ components: - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE + - PHQ9_SCORE + - GAD7_SCORE + - GRIP_STRENGTH + - PAIN_NRS + - WAIST_CIRCUMFERENCE + - WAIST_TO_HEIGHT value: type: number description: Daily mean (spot metrics) or SUM (cumulative). @@ -12746,6 +13817,12 @@ components: - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE + - PHQ9_SCORE + - GAD7_SCORE + - GRIP_STRENGTH + - PAIN_NRS + - WAIST_CIRCUMFERENCE + - WAIST_TO_HEIGHT description: Per-type daily series, keyed by MeasurementType. required: - series @@ -12979,6 +14056,12 @@ components: - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE + - PHQ9_SCORE + - GAD7_SCORE + - GRIP_STRENGTH + - PAIN_NRS + - WAIST_CIRCUMFERENCE + - WAIST_TO_HEIGHT value: type: number unit: @@ -13538,6 +14621,24 @@ components: minimum: 0 maximum: 9007199254740991 description: Nights still needed before the debt is asserted (0 when ready). + source: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + description: v1.25.0 — the active source the debt is resolved FROM, picked off the user's `sleepDebt` source ladder. + `COMPUTED` is HealthLog's own rolling-balance estimate (the only producer today); a provider value would + be a device-native debt. Clients explain the figure when it is `COMPUTED`. required: - state - debtMinutes @@ -13545,6 +14646,7 @@ components: - nightsCounted - windowNights - nightsUntilReady + - source additionalProperties: false chronotype: type: object @@ -16051,9 +17153,18 @@ components: doctorReport: type: boolean description: Whether the "doctorReport" module is enabled for this account. + environment: + type: boolean + description: Whether the "environment" module is enabled for this account. mcp: type: boolean description: Whether the "mcp" module is enabled for this account. + inboundDocuments: + type: boolean + description: Whether the "inboundDocuments" module is enabled for this account. + mentalHealth: + type: boolean + description: Whether the "mentalHealth" module is enabled for this account. required: - cycle - mood @@ -16068,7 +17179,10 @@ components: - insights - medications - doctorReport + - environment - mcp + - inboundDocuments + - mentalHealth additionalProperties: false description: Fully-resolved per-user module enable/disable map. Every toggleable module key is present. `false` means the surface should disappear end-to-end (nav, dashboard, insights, …). `cycle` mirrors `cycleTrackingEnabled`; @@ -16420,6 +17534,24 @@ components: - OURA - TELEGRAM - MCP + sleepDebt: + maxItems: 8 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP metricPriority: type: object properties: @@ -16747,6 +17879,24 @@ components: - OURA - TELEGRAM - MCP + sleepDebt: + maxItems: 8 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP additionalProperties: false deviceTypePriority: type: object @@ -17120,6 +18270,24 @@ components: - OURA - TELEGRAM - MCP + sleepDebt: + maxItems: 8 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP metricPriority: type: object properties: @@ -17447,6 +18615,24 @@ components: - OURA - TELEGRAM - MCP + sleepDebt: + maxItems: 8 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP additionalProperties: false deviceTypePriority: type: object @@ -20229,6 +21415,67 @@ components: description: "Generic per-metric assessment envelope. Identical shape to the seven specialised `*-status` cards so the `InsightStatusCard` consumes it unchanged. Read-only + stale-while-revalidate: a cache miss warms a generation out of band and serves the last-good text meanwhile." + BiomarkerAssessmentResponseEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/BiomarkerAssessmentResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + BiomarkerAssessmentResponse: + type: object + properties: + hasProvider: + type: boolean + description: False when the user has no usable AI provider — `text` then carries the generic no-key guidance. + text: + anyOf: + - type: string + - type: "null" + description: The assessment narrative (plain text, rendered as React text children). Null while a first generation is + preparing, or when the marker has no numeric readings. + cached: + type: boolean + description: True when `text` is served from cache (incl. last-good). + updatedAt: + 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: When the served assessment was generated; null when none. + preparing: + description: True when a first assessment is being generated out of band and no prior text exists yet — the client polls + until it lands. + type: boolean + revalidating: + description: True when `text` is served from last-good cache (stale-while-revalidate) while a fresh generation is in + flight. The client keeps polling on `preparing || revalidating` (bounded) so the open card upgrades to the + warmed assessment without a remount. + type: boolean + insufficient: + description: True when the marker has no numeric readings; no assessment is generated (no LLM call). The card is not + rendered. + type: boolean + required: + - hasProvider + - text + - cached + - updatedAt + additionalProperties: false + description: "Per-biomarker assessment envelope. Identical shape to the generic metric-status card so the + `InsightStatusCard` consumes it unchanged. Read-only + stale-while-revalidate: a cache miss warms a generation + out of band and serves the last-good text meanwhile; the assessment regenerates only when a new reading lands." DerivedMetricResponseEnvelope: type: object properties: @@ -20663,6 +21910,244 @@ components: - windowDays - interpretation additionalProperties: false + InsightsHealthStatusEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/InsightsHealthStatus" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + InsightsHealthStatus: + type: object + properties: + present: + type: boolean + description: True when at least one deviation or shift is surfaced. + deviations: + type: array + items: + type: object + properties: + type: + type: string + description: MeasurementType token. + value: + type: number + description: Today's value. + center: + type: number + description: Personal band center (median). + low: + type: number + high: + type: number + direction: + type: string + enum: + - above + - below + required: + - type + - value + - center + - low + - high + - direction + additionalProperties: false + shifts: + type: array + items: + type: object + properties: + metric: + type: string + description: MeasurementType token. + breakDate: + type: string + description: YYYY-MM-DD of the new level's first day. + beforeMean: + type: number + afterMean: + type: number + direction: + type: string + enum: + - up + - down + required: + - metric + - breakDate + - beforeMean + - afterMean + - direction + additionalProperties: false + generatedAt: + type: string + description: ISO-8601 instant the read was computed. + required: + - present + - deviations + - shifts + - generatedAt + additionalProperties: false + description: Vitals drifting from the user's personal normal — band deviations plus dated, sustained level shifts. + Awareness only, never a diagnosis. + InsightsBreathingScreeningEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/InsightsBreathingScreening" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + InsightsBreathingScreening: + type: object + properties: + present: + type: boolean + nights: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Nights with a per-night index reading. + recentMeanIndex: + anyOf: + - type: number + - type: "null" + description: Mean of the index readings (lower-better); null when none. + trend: + anyOf: + - type: string + enum: + - up + - down + - stable + - type: "null" + description: Recent index vs the prior window; null when too few nights. + eventCount: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Device-flagged breathing-disturbance / apnea events. + classification: + anyOf: + - type: string + enum: + - not-elevated + - elevated + - type: "null" + description: The device's own classification; null when no data. + generatedAt: + type: string + required: + - present + - nights + - recentMeanIndex + - trend + - eventCount + - classification + - generatedAt + additionalProperties: false + description: Sleep-breathing-disturbance screening signal — a screening signal only, never a diagnosis. + InsightsLabsChangesEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/InsightsLabsChanges" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + InsightsLabsChanges: + type: object + properties: + present: + type: boolean + latestDate: + anyOf: + - type: string + - type: "null" + description: YYYY-MM-DD of the most-recent panel. + previousDate: + anyOf: + - type: string + - type: "null" + description: YYYY-MM-DD of the prior panel. + changes: + type: array + items: + type: object + properties: + analyte: + type: string + unit: + type: string + latest: + type: number + previous: + type: number + delta: + type: number + description: Signed latest − previous. + direction: + type: string + enum: + - up + - down + - flat + status: + type: string + enum: + - in-range + - below + - above + - unknown + required: + - analyte + - unit + - latest + - previous + - delta + - direction + - status + additionalProperties: false + generatedAt: + type: string + required: + - present + - latestDate + - previousDate + - changes + - generatedAt + additionalProperties: false + description: Per-analyte change between the two most-recent numeric lab panels. Neutral framing, never a diagnosis. MoodTagTreeEnvelope: type: object properties: @@ -22966,6 +24451,804 @@ components: (median) recovery gap (null below the min-sample floor — withholds a thin claim), a recurrence-by-month tally, a per-type breakdown, and the dominant driving vital (`gapDriverType`, null when no gap is surfaced). Retrospective only." + ListMentalHealthAssessmentsEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ListMentalHealthAssessmentsResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ListMentalHealthAssessmentsResponse: + type: object + properties: + assessments: + type: array + items: + $ref: "#/components/schemas/MentalHealthAssessment" + required: + - assessments + additionalProperties: false + MentalHealthAssessment: + type: object + properties: + id: + type: string + instrument: + type: string + enum: + - PHQ9 + - GAD7 + locale: + type: string + version: + type: string + totalScore: + type: number + severityBand: + type: string + item9Flagged: + type: boolean + crisisShownAt: + anyOf: + - type: string + - type: "null" + takenAt: + type: string + createdAt: + type: string + required: + - id + - instrument + - locale + - version + - totalScore + - severityBand + - item9Flagged + - crisisShownAt + - takenAt + - createdAt + additionalProperties: false + description: One completed screener administration. `severityBand` is a descriptive label for the screen (never a + diagnosis). `item9Flagged` is true when the PHQ-9 self-harm item was answered > 0 — the client MUST show crisis + resources when it is. Raw per-item answers are intentionally absent. + CreateMentalHealthAssessmentEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/CreateMentalHealthAssessmentResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CreateMentalHealthAssessmentResponse: + type: object + properties: + assessment: + $ref: "#/components/schemas/MentalHealthAssessment" + actionThreshold: + type: number + crisis: + anyOf: + - $ref: "#/components/schemas/CrisisResourceSet" + - type: "null" + required: + - assessment + - actionThreshold + - crisis + additionalProperties: false + CrisisResourceSet: + type: object + properties: + emergencyNumber: + type: string + resources: + type: array + items: + $ref: "#/components/schemas/CrisisResource" + required: + - emergencyNumber + - resources + additionalProperties: false + description: Locale-aware crisis-resource signposting, present only when item-9 is flagged. Names resolve via i18n on + the client; contacts are literal. + CrisisResource: + type: object + properties: + id: + type: string + contacts: + type: array + items: + type: string + required: + - id + - contacts + additionalProperties: false + ListAllergiesEnvelope: + type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/Allergy" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + Allergy: + type: object + properties: + id: + type: string + substance: + type: string + category: + type: string + enum: + - FOOD + - MEDICATION + - ENVIRONMENT + - BIOLOGIC + - OTHER + type: + type: string + enum: + - ALLERGY + - INTOLERANCE + severity: + anyOf: + - type: string + enum: + - MILD + - MODERATE + - SEVERE + - type: "null" + status: + type: string + enum: + - ACTIVE + - INACTIVE + - RESOLVED + onsetAt: + anyOf: + - type: string + - type: "null" + reaction: + anyOf: + - type: string + - type: "null" + note: + anyOf: + - type: string + - type: "null" + createdAt: + type: string + updatedAt: + type: string + required: + - id + - substance + - category + - type + - severity + - status + - onsetAt + - reaction + - note + - createdAt + - updatedAt + additionalProperties: false + description: A stored allergy/intolerance record. `reaction` + `note` are the decrypted free-text (or null on a + key-rotation gap — fail-soft, never 500). `severity`/`onsetAt` are null when not assessed/known. + CreateAllergyEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/Allergy" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + GetAllergyEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/Allergy" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + UpdateAllergyEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/Allergy" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DeleteAllergyEnvelope: + type: object + properties: + data: + type: object + properties: + deleted: + type: boolean + required: + - deleted + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ListFamilyHistoryEnvelope: + type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/FamilyHistoryEntry" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + FamilyHistoryEntry: + type: object + properties: + id: + type: string + relationship: + type: string + enum: + - MOTHER + - FATHER + - SISTER + - BROTHER + - DAUGHTER + - SON + - GRANDMOTHER_MATERNAL + - GRANDFATHER_MATERNAL + - GRANDMOTHER_PATERNAL + - GRANDFATHER_PATERNAL + - AUNT + - UNCLE + - COUSIN + - HALF_SIBLING + - OTHER + condition: + type: string + ageAtOnset: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + note: + anyOf: + - type: string + - type: "null" + createdAt: + type: string + updatedAt: + type: string + required: + - id + - relationship + - condition + - ageAtOnset + - note + - createdAt + - updatedAt + additionalProperties: false + description: "A stored family-history entry: one condition for one relative. `note` is the decrypted free-text (or null + on a key-rotation gap — fail-soft, never 500). `ageAtOnset` is null when unknown." + CreateFamilyHistoryEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/FamilyHistoryEntry" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + GetFamilyHistoryEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/FamilyHistoryEntry" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + UpdateFamilyHistoryEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/FamilyHistoryEntry" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DeleteFamilyHistoryEnvelope: + type: object + properties: + data: + type: object + properties: + deleted: + type: boolean + required: + - deleted + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + InboundDocumentListEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/InboundDocumentList" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + InboundDocumentList: + type: object + properties: + documents: + type: array + items: + $ref: "#/components/schemas/InboundDocument" + required: + - documents + additionalProperties: false + InboundDocument: + type: object + properties: + id: + type: string + kind: + type: string + enum: + - DOCTOR_REPORT + - DISCHARGE_LETTER + - OTHER + filename: + anyOf: + - type: string + - type: "null" + mimeType: + type: string + byteSize: + type: number + status: + type: string + enum: + - EXTRACTING + - EXTRACTED + - FAILED + - CONFIRMED + - DISCARDED + providerType: + anyOf: + - type: string + - type: "null" + reportDate: + anyOf: + - type: string + - type: "null" + errorReason: + anyOf: + - type: string + - type: "null" + factCount: + type: number + pendingCount: + type: number + createdAt: + type: string + updatedAt: + type: string + required: + - id + - kind + - filename + - mimeType + - byteSize + - status + - providerType + - reportDate + - errorReason + - factCount + - pendingCount + - createdAt + - updatedAt + additionalProperties: false + description: An uploaded clinical document. The raw bytes are stored encrypted at rest and never returned; this is the + metadata + staging summary. + InboundDocumentEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/InboundDocument" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + InboundDocumentDetailEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/InboundDocumentDetail" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + InboundDocumentDetail: + type: object + properties: + id: + type: string + kind: + type: string + enum: + - DOCTOR_REPORT + - DISCHARGE_LETTER + - OTHER + filename: + anyOf: + - type: string + - type: "null" + mimeType: + type: string + byteSize: + type: number + status: + type: string + enum: + - EXTRACTING + - EXTRACTED + - FAILED + - CONFIRMED + - DISCARDED + providerType: + anyOf: + - type: string + - type: "null" + reportDate: + anyOf: + - type: string + - type: "null" + errorReason: + anyOf: + - type: string + - type: "null" + factCount: + type: number + pendingCount: + type: number + createdAt: + type: string + updatedAt: + type: string + facts: + type: array + items: + $ref: "#/components/schemas/ExtractedFact" + required: + - id + - kind + - filename + - mimeType + - byteSize + - status + - providerType + - reportDate + - errorReason + - factCount + - pendingCount + - createdAt + - updatedAt + - facts + additionalProperties: false + ExtractedFact: + type: object + properties: + id: + type: string + factType: + type: string + enum: + - CONDITION + - OBSERVATION + - MEDICATION_STATEMENT + status: + type: string + enum: + - PENDING + - APPROVED + - REJECTED + confidence: + type: number + needsReview: + type: boolean + data: + type: object + propertyNames: + type: string + additionalProperties: {} + provenance: + type: object + properties: + sourceText: + type: string + page: + anyOf: + - type: number + - type: "null" + confidence: + type: number + required: + - sourceText + - page + - confidence + additionalProperties: false + committedRecordId: + anyOf: + - type: string + - type: "null" + committedRecordType: + anyOf: + - type: string + - type: "null" + required: + - id + - factType + - status + - confidence + - needsReview + - data + - provenance + - committedRecordId + - committedRecordType + additionalProperties: false + description: One STATED fact transcribed from the document, FHIR-staged (Condition / Observation / MedicationStatement) + with per-field provenance + a confidence gate. STATED status only — codes (SNOMED/ICD-10/LOINC/RxNorm/ATC) are + present only when the document wrote them; no range-flag, no inferred links. `needsReview` is true when the fact + scored below the confidence floor (it cannot be approved until edited). + InboundDiscardEnvelope: + type: object + properties: + data: + type: object + properties: + id: + type: string + discarded: + type: boolean + required: + - id + - discarded + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ExtractedFactEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ExtractedFact" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + InboundConfirmEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/InboundConfirmResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + InboundConfirmResponse: + type: object + properties: + approved: + type: array + items: + type: object + properties: + factId: + type: string + recordType: + type: string + recordId: + type: string + required: + - factId + - recordType + - recordId + additionalProperties: false + rejected: + type: array + items: + type: string + needsReview: + type: array + items: + type: string + failed: + type: array + items: + type: object + properties: + factId: + type: string + reason: + type: string + required: + - factId + - reason + additionalProperties: false + required: + - approved + - rejected + - needsReview + - failed + additionalProperties: false + description: "The outcome per decision: `approved` (committed, with the new record ref), `rejected` (discarded), + `needsReview` (refused — edit first, fail-closed), `failed` (a per-fact commit miss, e.g. a numeric observation + with no unit)." DisclaimerAckEnvelope: type: object properties: @@ -23201,6 +25484,9 @@ components: - cycle-summary - signals - rhythm-events + - health-status + - breathing + - labs-changes visible: type: boolean order: @@ -23214,7 +25500,7 @@ components: additionalProperties: false tiles: minItems: 1 - maxItems: 50 + maxItems: 61 type: array items: type: object @@ -23228,6 +25514,7 @@ components: - oxygen - body-temperature - respiratory-rate + - pain - weight - bmi - body-water @@ -23237,6 +25524,8 @@ components: - muscle-mass - visceral-fat - lean-body-mass + - waist + - waist-to-height - steps - active-energy - workouts @@ -23253,6 +25542,7 @@ components: - six-minute-walk - stair-ascent-speed - stair-descent-speed + - grip-strength - sleep - breathing-disturbances - resting-pulse diff --git a/docs/integrations/ai-providers.md b/docs/integrations/ai-providers.md index 1b1600b7e..2cdb85c7b 100644 --- a/docs/integrations/ai-providers.md +++ b/docs/integrations/ai-providers.md @@ -198,6 +198,30 @@ The Coach drawer surfaces a "Local" badge when generation resolves to the local provider, so users who pinned a local endpoint can see at a glance that their context stayed on the host. +## What the Coach is — and isn't + +The Coach is an informational assistant that works from your own +logged data. It is not a doctor, what it says is not medical advice, +and it does not diagnose, prescribe, or change treatment — those +boundaries are written into its system prompt as safety contracts, not +left to chance. It describes patterns observationally ("your resting +heart rate has been rising over 14 days"), never as a verdict about a +named condition, and it defers diagnosis, dose, and drug-interaction +questions to a healthcare professional. This is HealthLog's +project-wide self-description standard: a personal health +record-keeping and wellness tool, not a medical device. The same +non-diagnostic framing governs the daily briefing, the insights, and +every surface that turns your data into words. + +The Coach can also reach out proactively — a calm, once-a-day morning +check-in when there is something genuine to surface (a recovery trend +easing off, blood pressure running a touch high). The nudge is warm +and localized, references the focus by category rather than quoting +your own words back, and stays silent when there is nothing real to +say or when you have engaged the Coach recently. It is an invitation, +never a streak-guilt prompt, and it inherits the same non-diagnostic +boundaries as the chat. + ## Caching contract Two caches sit in front of every provider call so generation budgets diff --git a/docs/integrations/data-import.md b/docs/integrations/data-import.md index a128097a8..e01d4247d 100644 --- a/docs/integrations/data-import.md +++ b/docs/integrations/data-import.md @@ -36,12 +36,8 @@ arrays: ```json { - "measurements": [ - /* … */ - ], - "moodEntries": [ - /* … */ - ] + "measurements": [/* … */], + "moodEntries": [/* … */] } ``` diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md index 03135bd6f..6e59c8a7f 100644 --- a/docs/self-hosting/getting-started.md +++ b/docs/self-hosting/getting-started.md @@ -16,6 +16,10 @@ already installed, longer if a TLS-fronted public hostname is in scope hold a 1.5 GB upload in `/tmp` while it parses. - **Outbound HTTPS** to `ghcr.io` (image pulls) plus whichever integration endpoints you plan to enable (Withings, OpenAI, etc.). + The opt-in environmental-context module additionally reaches + `api.open-meteo.com` for weather and daylight; it stays off until you + enable it and set a home location, and every outbound call goes + through the SSRF-guarded egress wrapper. - **Optional but recommended:** a reverse proxy that terminates TLS and forwards to `http://localhost:3000` (Caddy, Traefik, Nginx Proxy Manager, Coolify, or bare Nginx — see `reverse-proxy.md`). diff --git a/messages/de.json b/messages/de.json index ce28bb09c..a90fecd6a 100644 --- a/messages/de.json +++ b/messages/de.json @@ -35,7 +35,8 @@ "showPassword": "Passwort anzeigen", "hidePassword": "Passwort verbergen", "learnMore": "Mehr erfahren", - "dismiss": "Schließen" + "dismiss": "Schließen", + "moreInfo": "Mehr Informationen" }, "doctorReport": { "title": "Gesundheitsbericht", @@ -238,7 +239,9 @@ "cycle": "Zyklus", "labs": "Laborwerte", "vorsorge": "Vorsorge", - "illness": "Krankheit" + "illness": "Krankheit", + "documents": "Dokumente", + "mentalWellbeing": "Seelisches Wohlbefinden" }, "auth": { "login": "Anmelden", @@ -574,7 +577,15 @@ "bulkDeleteSuccess": "{count} Messungen gelöscht", "bulkDeleteError": "Fehler beim Löschen der ausgewählten Messungen", "bulkDeleteConfirmTitle": "{count} Messungen löschen?", - "bulkDeleteConfirmBody": "Die {count} ausgewählten Messungen werden endgültig gelöscht." + "bulkDeleteConfirmBody": "Die {count} ausgewählten Messungen werden endgültig gelöscht.", + "typePhq9Score": "PHQ-9-Wert", + "typeGad7Score": "GAD-7-Wert", + "typeGripStrength": "Griffstärke", + "typePainNrs": "Schmerz (0–10)", + "typeWaistCircumference": "Taillenumfang", + "typeWaistToHeight": "Taille-Größe-Verhältnis", + "unitScore": "Pkt.", + "unitRatio": "Verhältnis" }, "mood": { "title": "Stimmung", @@ -750,7 +761,8 @@ "sectionFactors": "Faktoren", "sectionNoteDetails": "Notiz & Details", "quickTagsHelp": "Häufig genutzte Tags – ein Tipp genügt", - "quickTagsExpand": "Weitere" + "quickTagsExpand": "Weitere", + "loadError": "Stimmungseinträge konnten nicht geladen werden." }, "medications": { "title": "Medikamente", @@ -1852,7 +1864,20 @@ "sat": "Samstag", "sun": "Sonntag" }, - "rangeBand": "Bereich" + "rangeBand": "Bereich", + "a11y": { + "summary": "{metric}-Diagramm. Bereich {min} bis {max}, aktuell {latest}, Trend {trend}.", + "noData": "{metric}-Diagramm. Keine Daten verfügbar.", + "trendUp": "steigend", + "trendDown": "fallend", + "trendFlat": "gleichbleibend", + "scatterSummary": "Korrelationsdiagramm, {count} Punkte: {xLabel} gegen {yLabel}.", + "scatterNoData": "Korrelationsdiagramm {xLabel} gegen {yLabel}. Keine Daten verfügbar.", + "moodHeatmap": "Stimmungs-Heatmap von {start} bis {end}, {days} Tage erfasst.", + "moodHeatmapEmpty": "Stimmungs-Heatmap, noch keine Tage erfasst.", + "complianceHeatmap": "Heatmap zur Medikamenteneinnahme von {start} bis {end}, {days} aktive Tage.", + "complianceHeatmapEmpty": "Heatmap zur Medikamenteneinnahme, noch keine aktiven Tage." + } }, "chart": { "overlay": { @@ -2037,7 +2062,11 @@ "noProviderTitle": "Kein KI-Anbieter verbunden", "noProviderDescription": "Das Tagesbriefing fasst deine Werte in Klartext zusammen. Dafür braucht HealthLog einen KI-Anbieter — nutze deinen eigenen Schlüssel (OpenAI, Anthropic) oder ein lokales Modell. Deine Daten bleiben dabei bei dir.", "noProviderAction": "Zu den KI-Einstellungen", - "staleNoProviderHint": "Dieses Briefing kann nicht aktualisiert werden — es ist kein KI-Anbieter verbunden." + "staleNoProviderHint": "Dieses Briefing kann nicht aktualisiert werden — es ist kein KI-Anbieter verbunden.", + "refreshFailedHint": "Briefing konnte nicht aktualisiert werden — es wird das letzte angezeigt.", + "retryAction": "Erneut versuchen", + "failedTitle": "Briefing konnte nicht erstellt werden", + "failedDescription": "Der KI-Anbieter hat das Zeitlimit überschritten oder einen Fehler zurückgegeben. Versuche es erneut oder erhöhe in den KI-Einstellungen das Antwort-Zeitlimit für ein langsames lokales Modell." }, "trendsRow": { "title": "Trends", @@ -2372,7 +2401,8 @@ "correlationMoodPulse": "Gibt es einen echten Zusammenhang zwischen meiner Stimmung und meinem Puls? Hilf mir, diesen Zusammenhang zu lesen.", "correlationWeightWeekday": "Folgt mein Gewicht einem Wochenmuster? Was sagt mir dieser Wochentags-Zusammenhang?", "recommendation": "Erzähl mir mehr zu dieser Empfehlung: \"{text}\" — warum trifft sie auf mich zu, und was sollte ich tun?" - } + }, + "assessmentPrompt": "Erkläre mir meine {metric}-Einschätzung — was bedeutet sie und was sollte ich tun?" }, "relativeJustNow": "gerade eben", "relativeMinutesAgoOne": "vor {count} Minute", @@ -2444,7 +2474,8 @@ "emptyState": { "title": "Noch kein Ruhepuls", "description": "Der Ruhepuls erscheint, sobald die Apple Watch oder ein kompatibles Withings-Gerät einen ruhigen Tag durchgetragen hat." - } + }, + "coachPrefill": "Ich habe noch keinen Ruhepuls erfasst — warum unterscheidet sich der tägliche Ruhewert von einem punktuellen Puls, und wie verbessere ich ihn?" }, "oxygenSaturation": { "title": "Sauerstoffsättigung", @@ -2670,7 +2701,8 @@ "caption": "Kumuliertes Defizit der letzten {nights} Nächte", "clearValue": "Ausgeglichen", "clearCaption": "Kein aufgelaufenes Defizit über die letzten {nights} Nächte", - "learning": "Dein Schlaf wird noch erfasst — {counted} Nächte bisher, noch {remaining} für eine verlässliche Bilanz." + "learning": "Dein Schlaf wird noch erfasst — {counted} Nächte bisher, noch {remaining} für eine verlässliche Bilanz.", + "computedInfo": "Das ist HealthLogs eigene Schätzung: eine gleitende Bilanz aus Bedarf minus Schlaf über die letzten Nächte, die Aufholschlaf wieder abbaut. Sie kann vom nativen Schlafdefizit eines Wearables abweichen, das ein eigenes Bedarfsmodell nutzt." }, "average": { "title": "Durchschnitt pro Nacht", @@ -3585,28 +3617,7 @@ }, "sleepQuality": { "title": "Schlafqualität", - "subtitle": "Nächtliche Werte deines Wearables", - "assessment": { - "metric": { - "SLEEP_SCORE": "dein Gesamt-Schlafscore", - "SLEEP_EFFICIENCY": "die Schlafeffizienz", - "SLEEP_PERFORMANCE": "die Schlafleistung", - "SLEEP_CONSISTENCY": "die Schlafkonstanz" - }, - "grade": { - "excellent": "ausgezeichnet", - "good": "solide", - "fair": "durchschnittlich", - "low": "eher niedrig" - }, - "clause": "Über die letzten 30 Nächte ist {metric} mit {value} {grade}.", - "closing": { - "excellent": "Behalte die Routine bei, die dich hierher bringt.", - "good": "Konstantere Zubett- und Aufstehzeiten sind meist der nächste Gewinn.", - "fair": "Ein konstantes Schlaffenster hebt diesen Wert erfahrungsgemäß am stärksten.", - "low": "Ein festes Herunterfahren am Abend und eine feste Aufstehzeit sind die ersten Hebel." - } - } + "subtitle": "Nächtliche Werte deines Wearables" }, "recovery": { "title": "Erholung", @@ -3666,7 +3677,86 @@ }, "updatedTodayAt": "Heute aktualisiert, {time}", "updatedYesterday": "Gestern aktualisiert", - "updatedOn": "Aktualisiert am {date}" + "updatedOn": "Aktualisiert am {date}", + "healthStatus": { + "sectionTitle": "Was sich verändert hat", + "subtitle": "Werte, die von deinem üblichen Bereich abweichen.", + "deviationAbove": "{vital} liegt über deinem üblichen Bereich.", + "deviationBelow": "{vital} liegt unter deinem üblichen Bereich.", + "shiftUp": "{vital} hat sich gegenüber deiner jüngsten Norm nach oben verschoben.", + "shiftDown": "{vital} hat sich gegenüber deiner jüngsten Norm nach unten verschoben.", + "rangeContext": "Aktuell {value}, sonst {low}–{high}.", + "shiftContext": "Etwa {before} davor, {after} seitdem." + }, + "breathingScreening": { + "sectionTitle": "Atmung im Schlaf", + "subtitle": "Ein Screening-Signal aus deinen Schlafdaten.", + "classificationElevated": "Dein Gerät hat erhöhte Atemstörungen gemeldet.", + "classificationNotElevated": "Die Einstufung deines Geräts ist nicht erhöht.", + "trendUp": "Höher als an deinen früheren Nächten.", + "trendDown": "Niedriger als an deinen früheren Nächten.", + "trendStable": "Stabil gegenüber deinen früheren Nächten.", + "nights": "{count} Nächte erfasst", + "indexLine": "jüngster Durchschnittsindex {value}", + "eventsFlagged": "{count} vom Gerät gemeldete Ereignisse", + "disclaimer": "Dies ist ein Screening-Signal, keine Diagnose — bitte mit einer Ärztin oder einem Arzt besprechen." + }, + "labsChanges": { + "sectionTitle": "Seit deinem letzten Laborbefund", + "subtitle": "Wie deine neuesten Werte im Vergleich zum vorherigen Befund stehen.", + "statusInRange": "im Normbereich", + "statusBelow": "unter dem Bereich", + "statusAbove": "über dem Bereich", + "directionFlat": "keine Änderung" + }, + "navGripStrength": "Griffkraft", + "navPainNrs": "Schmerz", + "navWaistCircumference": "Taille", + "navWaistToHeight": "Taille/Größe", + "gripStrength": { + "title": "Griffkraft", + "description": "Maximale Handgriffkraft — ein robuster Stellvertreter für die Ganzkörperkraft. Unter dem geschlechtsspezifischen Grenzwert (Männer 27 kg, Frauen 16 kg) deutet sie auf eine mögliche Sarkopenie hin; dein eigener Verlauf führt die Einschätzung.", + "chartTitle": "Griffkraft", + "emptyState": { + "title": "Noch keine Griffkraft", + "description": "Erfasse einen Wert mit einem Handdynamometer — notiere den besten von mehreren Versuchen — und der Verlauf erscheint hier.", + "cta": "Griffkraft erfassen" + }, + "coachPrefill": "Ich habe noch keine Griffkraft erfasst — was sagt sie über meine Muskelgesundheit aus, und wie messe und verbessere ich sie?" + }, + "pain": { + "title": "Schmerz (0–10)", + "description": "Selbstberichtete Schmerzintensität auf der numerischen Skala 0–10, wobei 0 keinen Schmerz und 10 den stärksten vorstellbaren Schmerz bedeutet. Eine anhaltende Änderung um 2 Punkte ist das beobachtenswerte Signal.", + "chartTitle": "Schmerz (0–10)", + "emptyState": { + "title": "Noch keine Schmerzwerte", + "description": "Erfasse auf der Skala 0–10, wie sich dein Schmerz gerade anfühlt, und der Verlauf erscheint hier.", + "cta": "Schmerzwert erfassen" + }, + "coachPrefill": "Ich habe noch keine Schmerzwerte erfasst — wie sollte ich Schmerz auf der Skala 0–10 verfolgen, und was gilt als bedeutsame Veränderung?" + }, + "waist": { + "title": "Taillenumfang", + "description": "Taillenumfang in Zentimetern — ein Marker für Bauchfett. Der WHO-Grenzwert für erhöhtes Risiko ist geschlechtsspezifisch (Männer 94 cm, Frauen 80 cm); dein eigener Verlauf führt die Einschätzung.", + "chartTitle": "Taillenumfang", + "emptyState": { + "title": "Noch keine Taillenwerte", + "description": "Miss deine Taille mittig zwischen der untersten Rippe und dem Hüftknochen am Ende einer normalen Ausatmung und erfasse den Wert hier.", + "cta": "Taillenumfang erfassen" + }, + "coachPrefill": "Ich habe noch keine Taillenwerte erfasst — wie messe ich meine Taille richtig, und was bedeutet der Wert für meine Gesundheit?" + }, + "waistToHeight": { + "title": "Taille-zu-Größe-Verhältnis", + "description": "Deine Taille geteilt durch deine Größe — die ethnienunabhängige Einzelzahl. Sie unter 0,5 zu halten (Taille unter der halben Körpergröße) ist das einfache Ziel.", + "chartTitle": "Taille-zu-Größe-Verhältnis", + "emptyState": { + "title": "Noch kein Taille-zu-Größe-Verhältnis", + "description": "Erfasse ein Taille-zu-Größe-Verhältnis, und der Verlauf erscheint hier. Faustregel: Halte deine Taille unter der halben Körpergröße.", + "cta": "Taille-zu-Größe-Verhältnis erfassen" + }, + "coachPrefill": "Ich habe noch kein Taille-zu-Größe-Verhältnis erfasst — warum ist es eine nützliche Zahl, und wie halte ich es unter 0,5?" + } }, "thresholds": { "defaultLabel": "Default", @@ -4012,7 +4102,8 @@ "skinTemperature": "Hauttemperatur", "respiratoryRate": "Atemfrequenz", "recovery": "Erholung", - "stress": "Stress" + "stress": "Stress", + "sleepDebt": "Schlafdefizit" }, "sourceLabels": { "WITHINGS": "Withings", @@ -4235,6 +4326,50 @@ "mcp": { "title": "MCP-Connector", "subtitle": "Verbinde einen externen Assistenten mit deinen eigenen Gesundheitsdaten." + }, + "anamnesis": { + "title": "Anamnese", + "subtitle": "Verwalte deine Allergien und deine Familienanamnese.", + "description": "Strukturierte Datensätze zu Allergien und Familienanamnese." + }, + "environment": { + "title": "Umwelt", + "subtitle": "Wetter, Tageslicht und Standort-Kontext.", + "disabledHint": "Aktiviere das Umwelt-Modul, um einen Heimatort festzulegen und tägliches Wetter und Tageslicht abzurufen.", + "searchPlaceholder": "Stadt suchen", + "search": "Suchen", + "searchError": "Ort konnte nicht gesucht werden.", + "use": "Verwenden", + "select": "Auswählen", + "saveError": "Änderung konnte nicht gespeichert werden.", + "homeSaved": "Heimatort gespeichert.", + "travelAdded": "Ortszeitraum hinzugefügt.", + "travelRemoved": "Ortszeitraum entfernt.", + "backfillQueued": "Nacherfassung eingeplant.", + "backfillError": "Nacherfassung konnte nicht gestartet werden.", + "startDate": "Startdatum", + "endDate": "Enddatum", + "storedDays": "{count} Tage Wetter gespeichert.", + "home": { + "title": "Heimatort", + "description": "Ein grober Standort auf Stadtebene – niemals eine genaue Spur. Wetter und Tageslicht werden für diesen Ort an Tagen ohne Ortszeitraum abgerufen, ab dem Tag der Festlegung.", + "current": "Aktuell: {label}", + "none": "Noch kein Heimatort festgelegt.", + "since": "Wetter wird ab {date} erfasst. Frühere Tage bleiben leer, bis du einen Ortszeitraum hinzufügst." + }, + "travel": { + "title": "Ortszeiträume", + "description": "Füge einen Ort und einen Zeitraum hinzu – eine Reise oder einen früheren Abschnitt deiner Verlaufsdaten. Der Zeitraum überschreibt für seine Tage deinen Wohnort, damit das Wetter zu deinem tatsächlichen Aufenthalt passt. So korrigierst du die Vergangenheit.", + "none": "Noch keine Ortszeiträume.", + "add": "{label} hinzufügen", + "remove": "Ortszeitraum entfernen" + }, + "backfill": { + "title": "Vergangene Tage nacherfassen", + "description": "Rufe Wetter für einen vergangenen Zeitraum ab und speichere es, damit ältere Tage in die Korrelationen einfließen. Der Start steht standardmäßig auf dem Gültigkeitsdatum deines Wohnorts; um weiter zurückzureichen, füge zuerst einen Ortszeitraum für diesen Bereich hinzu.", + "run": "Nacherfassen" + }, + "noResults": "Keine passenden Orte gefunden." } }, "profile": "Profil", @@ -6076,7 +6211,13 @@ "frequencyAria": "Häufigkeit der Coach-Hinweise wählen", "enabledToast": "Proaktive Coach-Hinweise aktiviert.", "disabledToast": "Proaktive Coach-Hinweise deaktiviert.", - "saveError": "Speichern fehlgeschlagen. Bitte später erneut versuchen." + "saveError": "Speichern fehlgeschlagen. Bitte später erneut versuchen.", + "suggestionsLabel": "Beispielvorschläge", + "suggestionsDesc": "Zeigt auf dem Coach-Bildschirm gelegentlich eine fertige Beispielfrage.", + "suggestionsAria": "Beispiel-Coach-Vorschläge umschalten", + "aiComposedLabel": "Den Coach den Anstoß formulieren lassen", + "aiComposedDesc": "Wenn ein Anbieter verfügbar ist, formuliert das Modell den proaktiven Anstoß statt der festen Vorlage. Standardmäßig aus.", + "aiComposedAria": "KI-formulierten proaktiven Anstoß umschalten" }, "lowStock": { "title": "Vorratswarnung", @@ -6173,7 +6314,9 @@ "resetToDefault": "Auf Standard zurücksetzen", "saveSuccess": "Zielbereich aktualisiert", "saveError": "Zielbereich konnte nicht gespeichert werden", - "resetSuccess": "Zielbereich auf Standard zurückgesetzt" + "resetSuccess": "Zielbereich auf Standard zurückgesetzt", + "validationOrder": "Der Minimalwert muss unter dem Maximalwert liegen.", + "validationBounds": "Werte innerhalb des unten angegebenen Bereichs eingeben." }, "currentValue": "Aktuell: {value} {unit}", "targetRangeValue": "Zielbereich: {min}–{max} {unit}", @@ -6305,21 +6448,18 @@ "dailyBody": "Wie geht es dir heute?" }, "coachNudges": { - "complianceTitle": "Dein Coach hätte einen Gedanken dazu", - "complianceBody": "Deine Einnahmen waren diese Woche lückenhaft. Sprich kurz mit dem Coach — vielleicht lässt sich der Plan vereinfachen.", - "bpTitle": "Blutdruck im Wochenmittel erhöht", - "bpBody": "Dein Blutdruck lag diese Woche im Schnitt über deinem Ziel. Der Coach kann die Werte mit dir einordnen.", - "scoreTitle": "Dein Erholungswert fällt", - "scoreBody": "Dein Erholungswert ist diese Woche deutlich gesunken. Der Coach hilft dir, mögliche Ursachen einzugrenzen.", - "selfContextTitle": "Hilf deinem Coach, dich besser zu kennen", - "selfContextBody": "Deine Selbstauskunft ist unvollständig oder länger nicht aktualisiert. Zwei Minuten in den Einstellungen halten den Kontext des Coachs frisch.", - "weightTitle": "Dein Gewicht entfernt sich vom Zielbereich", - "weightBody": "Dein Wochenmittel hat sich weiter von deinem Zielbereich entfernt. Der Coach kann mit dir in Ruhe Bilanz ziehen.", - "sleepDebtTitle": "Mehrere kurze Nächte diese Woche", - "sleepDebtBody": "Die meisten deiner letzten Nächte lagen unter deinem Schlafziel. Der Coach hilft dir, nach Mustern zu suchen.", - "measurementGapTitle": "Dein Protokoll ist still geworden", - "measurementGapBody": "Seit einer Woche kommen keine neuen Werte an. Ein kurzer Austausch mit dem Coach hilft beim Wiedereinstieg — oder deckt eine hängende Synchronisierung auf.", - "focusSuffix": "Du wolltest auf Folgendes achten: {focus}.", + "greetingMorning": "Guten Morgen", + "greetingMorningNamed": "Guten Morgen, {name}", + "greetingHey": "Hey", + "greetingHeyNamed": "Hey {name}", + "complianceBody": "Deine Einnahmen sind diese Woche etwas durcheinandergeraten. Wenn du magst, schauen wir gemeinsam, was es dir leichter machen würde.", + "bpBody": "Dein Blutdruck lag diese Woche etwas über deinem gewohnten Bereich. Wann immer du möchtest, sehe ich mir das gern mit dir an.", + "scoreBody": "Deine Erholung lässt seit ein paar Tagen etwas nach. Wenn dich interessiert, was dahintersteckt, bin ich da.", + "selfContextBody": "Deine Selbstauskunft ist länger nicht aktualisiert. Wenn du einen Moment hast, hilft mir eine Auffrischung, dich besser zu verstehen.", + "weightBody": "Dein Gewicht hat sich zuletzt etwas von deinem Zielbereich entfernt. Wenn du magst, ziehen wir in Ruhe gemeinsam Bilanz.", + "sleepDebtBody": "Einige deiner Nächte waren diese Woche recht kurz. Wenn du willst, suchen wir gemeinsam nach einem Muster.", + "measurementGapBody": "Dein Protokoll ist seit etwa einer Woche still. Wann immer es dir passt, hilft ein kurzer Austausch beim Wiedereinstieg.", + "focusNote": "Das passt auch zu dem, was du selbst im Blick behalten möchtest.", "questionConditions": "Hast du chronische Erkrankungen, die der Coach kennen sollte?", "questionFocus": "Gibt es etwas Bestimmtes, worauf der Coach achten soll?", "questionAllergies": "Hast du Allergien oder Unverträglichkeiten?", @@ -6358,6 +6498,11 @@ "label": "Passphrase", "error": "Diese Passphrase ist nicht korrekt.", "submit": "Entsperren" + }, + "loadError": { + "title": "Diese geteilte Ansicht konnte nicht geladen werden", + "description": "Beim Öffnen dieser geteilten Gesundheitsdaten ist ein Fehler aufgetreten. Der Link ist möglicherweise abgelaufen, oder es lag ein vorübergehendes Problem vor. Versuche es erneut oder bitte die Person, die den Link geteilt hat, um einen neuen.", + "retry": "Erneut versuchen" } }, "cycle": { @@ -6831,7 +6976,8 @@ "empty": "Keine anstehenden Vorsorge-Erinnerungen.", "openLink": "Vorsorge öffnen" }, - "showMeasurements": "Messergebnisse anzeigen" + "showMeasurements": "Messergebnisse anzeigen", + "loadError": "Erinnerungen konnten nicht geladen werden." }, "moduleList": { "viewToggleLabel": "Ansicht", @@ -6942,7 +7088,7 @@ "deletedToast": "Biomarker gelöscht", "deleteError": "Biomarker konnte nicht gelöscht werden", "deleteConfirmTitle": "Biomarker löschen?", - "deleteConfirmDescription": "Die Definition wird entfernt. Bestehende Werte bleiben erhalten, aber ohne Verknüpfung.", + "deleteConfirmDescription": "Der Biomarker und alle erfassten Werte werden dauerhaft gelöscht. Dies kann nicht rückgängig gemacht werden.", "emptyTitle": "Noch keine Biomarker", "emptyDescription": "Lege Marker wie LDL oder HbA1c an, um Werte zu erfassen.", "loadError": "Biomarker konnten nicht geladen werden", @@ -6977,7 +7123,14 @@ "unhiddenToast": "Biomarker wiederhergestellt", "hideError": "Biomarker konnte nicht aktualisiert werden", "allHidden": "Alle Biomarker sind ausgeblendet.", - "hiddenSection": "Ausgeblendet" + "hiddenSection": "Ausgeblendet", + "targetRange": { + "title": "Zielbereich anpassen", + "description": "Lege die untere und obere Referenz für diesen Marker fest. Lass ein Feld leer für eine offene Grenze.", + "save": "Bereich speichern", + "savedToast": "Zielbereich aktualisiert", + "saveError": "Zielbereich konnte nicht aktualisiert werden" + } }, "catalog": { "total-cholesterol": "Gesamtcholesterin", @@ -7044,8 +7197,16 @@ "hemoglobin": "Das sauerstofftransportierende Protein in den roten Blutkörperchen.", "hematocrit": "Der Anteil der roten Blutkörperchen am Blutvolumen.", "wbc": "Gesamtzahl der weißen Blutkörperchen, der zirkulierenden Abwehr des Immunsystems.", - "platelets": "Zellfragmente, die sich zusammenlagern und die Blutgerinnung bewirken." - } + "platelets": "Zellfragmente, die sich zusammenlagern und die Blutgerinnung bewirken.", + "apob": "Zählt atherogene Partikel direkt; oft ein besserer Risikomarker als LDL-C, wenn beide auseinandergehen.", + "lp-a": "Ein weitgehend vererbter, lebenslanger Risikofaktor — meist einmal gemessen. In molaren Einheiten (nmol/L) angeben.", + "fasting-insulin": "Ergänzt den Nüchtern-Blutzucker zur Abschätzung der Insulinresistenz (HOMA-IR).", + "omega-3-index": "Anteil von EPA+DHA in den roten Blutkörperchen; 8–12 % gelten als kardioprotektiver Bereich." + }, + "apob": "Apolipoprotein B (ApoB)", + "lp-a": "Lipoprotein(a)", + "fasting-insulin": "Nüchtern-Insulin", + "omega-3-index": "Omega-3-Index" }, "noteLoadFailed": "Notiz konnte nicht geladen werden; sie bleibt beim Speichern unverändert.", "showingLatestOf": "Die neuesten {shown} von {total} Messwerten werden angezeigt", @@ -7092,7 +7253,8 @@ "fileType": "Lade ein JPEG, PNG, WebP oder PDF hoch.", "extractFailed": "Der Befund konnte nicht gelesen werden. Versuche ein schärferes Foto.", "pdfNeedsAnthropic": "PDF-Scannen benötigt einen Claude-Bildanbieter; lade stattdessen ein Foto hoch." - } + }, + "loadMoreReadings": "Weitere Werte laden" }, "modules": { "cycle": { @@ -7150,6 +7312,18 @@ "mcp": { "label": "Externer Assistentenzugriff (MCP)", "description": "Einem MCP-fähigen Assistenten Lesezugriff auf deine Gesundheitsdaten über ein begrenztes Token erlauben. Standardmäßig aus." + }, + "environment": { + "label": "Umwelt", + "description": "Tägliches Wetter und Tageslicht für deinen Ort einbeziehen, um Zusammenhänge mit Stimmung, Schlaf und Vitalwerten zu erkennen. Standardmäßig aus." + }, + "inboundDocuments": { + "label": "Eingehende Dokumente", + "description": "Lade einen Arztbericht oder Entlassbrief hoch; prüfe die erkannten Angaben, bevor sie gespeichert werden. Standardmäßig aus." + }, + "mentalHealth": { + "label": "Seelisches Wohlbefinden", + "description": "Freiwillige PHQ-9- und GAD-7-Selbsttests ergänzend zum Stimmungstagebuch. Standardmäßig aus; Antworten sind verschlüsselt und bleiben aus dem KI-Coach heraus." } }, "illness": { @@ -7202,8 +7376,8 @@ "note": "Notiz", "notePlaceholder": "Was erwähnenswert ist (verschlüsselt).", "saveError": "Die Episode konnte nicht gespeichert werden. Bitte erneut versuchen.", - "parent": "Part of", - "parentNone": "Not linked" + "parent": "Teil von", + "parentNone": "Nicht verknüpft" }, "sheet": { "title": "{date} erfassen", @@ -7284,35 +7458,36 @@ "fever": "Temperatur: {value} °C", "empty": "Noch keine Tage erfasst." }, - "loadError": "Could not load this episode.", - "backToList": "Back to journal", + "loadError": "Diese Episode konnte nicht geladen werden.", + "backToList": "Zurück zum Journal", "detail": { "note": "Note" }, "edit": { - "title": "Edit episode", - "description": "Update this episode's details." + "title": "Episode bearbeiten", + "description": "Aktualisiere die Details dieser Episode." }, "menu": { - "label": "Episode actions" + "label": "Episodenaktionen" }, "deleteConfirm": { - "title": "Delete this episode?", - "body": "“{label}” and all its day-logs will be removed. This cannot be undone." + "title": "Diese Episode löschen?", + "body": "„{label}“ und alle zugehörigen Tageseinträge werden entfernt. Dies kann nicht rückgängig gemacht werden." }, "vital": { - "RESTING_HEART_RATE": "Resting heart rate", - "HEART_RATE_VARIABILITY": "Heart-rate variability", + "RESTING_HEART_RATE": "Ruheherzfrequenz", + "HEART_RATE_VARIABILITY": "Herzfrequenzvariabilität", "RECOVERY_SCORE": "Recovery", - "OXYGEN_SATURATION": "Oxygen saturation", - "BODY_TEMPERATURE": "Body temperature", - "SKIN_TEMPERATURE": "Skin temperature", - "RESPIRATORY_RATE": "Respiratory rate", - "BLOOD_PRESSURE_SYS": "Systolic blood pressure", - "BLOOD_PRESSURE_DIA": "Diastolic blood pressure", + "OXYGEN_SATURATION": "Sauerstoffsättigung", + "BODY_TEMPERATURE": "Körpertemperatur", + "SKIN_TEMPERATURE": "Hauttemperatur", + "RESPIRATORY_RATE": "Atemfrequenz", + "BLOOD_PRESSURE_SYS": "Systolischer Blutdruck", + "BLOOD_PRESSURE_DIA": "Diastolischer Blutdruck", "PULSE": "Pulse", "WEIGHT": "Weight" - } + }, + "listLoadError": "Episoden konnten nicht geladen werden." }, "coach": { "reminderSuggestion": { @@ -7372,5 +7547,318 @@ "glucoseHighDoctor": "Zwei aktuelle Messungen lagen bei {value} mg/dL oder höher. Miss erneut, trink ausreichend und kontaktiere möglichst bald deine Ärztin oder deinen Arzt, wenn es so hoch bleibt.", "glucoseHighEmergency": "Zwei aktuelle Messungen lagen bei {value} mg/dL oder höher und du hast Beschwerden angegeben. Bei Erbrechen, schneller Atmung oder Verwirrtheit sofort den Notruf wählen." } + }, + "version": { + "updateAvailable": { + "title": "Eine neue Version ist verfügbar", + "reload": "Neu laden", + "dismiss": "Schließen" + } + }, + "mentalHealth": { + "pageTitle": "Check-in zum seelischen Wohlbefinden", + "pageDescription": "Zwei validierte, freiwillige Selbsttests — PHQ-9 (Stimmung) und GAD-7 (Sorgen) — ergänzend zu deinem Stimmungstagebuch. Aussagekräftig ist der Verlauf über die Zeit, nicht ein einzelner Wert.", + "disclaimer": "Dies ist ein Screening-Selbsttest, keine Diagnose. Ein hoher oder niedriger Wert bedeutet nicht, dass eine Erkrankung vorliegt oder nicht. Nur eine qualifizierte Fachperson kann eine Diagnose stellen. Bei Sorgen sprich bitte mit deiner Ärztin oder deinem Arzt.", + "optInNote": "Freiwillig und privat: Deine einzelnen Antworten sind verschlüsselt, bleiben aus dem KI-Coach und aus Exporten heraus und werden nie geteilt.", + "attributionLabel": "Quelle", + "choosePrompt": "Check-in auswählen", + "start": "Starten", + "back": "Zurück", + "stem": "Wie oft fühltest du dich im Verlauf der letzten 2 Wochen durch die folgenden Beschwerden beeinträchtigt?", + "submit": "Ergebnis ansehen", + "submitting": "Wird gespeichert…", + "saved": "Gespeichert", + "error": "Etwas ist schiefgelaufen. Bitte versuche es erneut.", + "considerProfessional": "Dein Wert liegt in einem Bereich, in dem ein Gespräch mit einer Fachperson helfen kann. Das ist ein behutsamer Hinweis, keine Diagnose.", + "instrument": { + "phq9": "PHQ-9 — Stimmung", + "gad7": "GAD-7 — Sorgen" + }, + "instrumentSub": { + "phq9": "Depressions-Screening, 9 Fragen", + "gad7": "Angst-Screening, 7 Fragen" + }, + "options": { + "0": "Überhaupt nicht", + "1": "An einzelnen Tagen", + "2": "An mehr als der Hälfte der Tage", + "3": "Beinahe jeden Tag" + }, + "functionalTitle": "Wenn du eine oder mehrere Beschwerden angegeben hast: Wie sehr haben diese deine Arbeit, deinen Haushalt oder den Umgang mit anderen Menschen erschwert?", + "functional": { + "0": "Überhaupt nicht erschwert", + "1": "Etwas erschwert", + "2": "Sehr erschwert", + "3": "Extrem erschwert" + }, + "result": { + "title": "Dein Ergebnis", + "totalLabel": "Gesamtwert", + "bandLabel": "Bereich für dieses Screening", + "takeAnother": "Weiteren Check-in machen" + }, + "band": { + "PHQ9": { + "minimal": "Keine / minimal", + "mild": "Leicht", + "moderate": "Mittelgradig", + "modSevere": "Mittelschwer", + "severe": "Schwer" + }, + "GAD7": { + "minimal": "Keine / minimal", + "mild": "Leicht", + "moderate": "Mittelgradig", + "severe": "Schwer" + } + }, + "history": { + "title": "Dein Verlauf", + "empty": "Noch keine Check-ins. Dein Verlauf erscheint hier, sobald du einen abgeschlossen hast." + }, + "crisis": { + "title": "Du musst das nicht allein durchstehen", + "intro": "Du hast Gedanken angegeben, lieber tot zu sein oder dir etwas anzutun. Es gibt jetzt sofort Unterstützung — sich zu melden ist ein Zeichen von Stärke.", + "ifDanger": "Wenn du in unmittelbarer Gefahr bist, ruf jetzt {emergency} an.", + "resourcesTitle": "Menschen, die helfen können" + }, + "items": { + "phq9": { + "1": "Wenig Interesse oder Freude an Ihren Tätigkeiten", + "2": "Niedergeschlagenheit, Schwermut oder Hoffnungslosigkeit", + "3": "Schwierigkeiten, ein- oder durchzuschlafen, oder vermehrter Schlaf", + "4": "Müdigkeit oder Gefühl, keine Energie zu haben", + "5": "Verminderter Appetit oder übermäßiges Bedürfnis zu essen", + "6": "Schlechte Meinung von sich selbst; Gefühl, ein Versager zu sein oder die Familie enttäuscht zu haben", + "7": "Schwierigkeiten, sich auf etwas zu konzentrieren, z. B. beim Zeitunglesen oder Fernsehen", + "8": "Waren Ihre Bewegungen oder Ihre Sprache so verlangsamt, dass es auch anderen auffallen würde? Oder waren Sie im Gegenteil „zappelig“ oder ruhelos und hatten einen stärkeren Bewegungsdrang als sonst?", + "9": "Gedanken, dass Sie lieber tot wären oder sich Leid zufügen möchten" + }, + "gad7": { + "1": "Nervosität, Ängstlichkeit oder Anspannung", + "2": "Nicht in der Lage sein, Sorgen zu stoppen oder zu kontrollieren", + "3": "Übermäßige Sorgen bezüglich verschiedener Angelegenheiten", + "4": "Schwierigkeiten zu entspannen", + "5": "Rastlosigkeit, sodass Stillsitzen schwerfällt", + "6": "Schnelle Verärgerung oder Gereiztheit", + "7": "Gefühl der Angst, als würde etwas Schlimmes passieren" + } + }, + "crisisResource": { + "telefonSeelsorge": { + "name": "TelefonSeelsorge (rund um die Uhr, kostenlos, vertraulich)" + }, + "nummerGegenKummer": { + "name": "Nummer gegen Kummer (für junge Menschen)" + }, + "krisenchat": { + "name": "krisenchat (24/7-Chat, junge Menschen)" + }, + "findahelpline": { + "name": "Find a Helpline (weltweites Verzeichnis)" + }, + "euEmotionalSupport": { + "name": "Telefonseelsorge-Hotline" + }, + "lifeline988": { + "name": "988 Suicide & Crisis Lifeline" + }, + "crisisTextLine": { + "name": "Crisis Text Line" + } + } + }, + "records": { + "disclaimer": "Das sind deine eigenen Angaben, keine ärztliche Diagnose. HealthLog stellt keine Diagnose, behandelt nicht und deutet nichts.", + "allergies": { + "cardTitle": "Allergien & Unverträglichkeiten", + "cardDescription": "Stoffe, auf die du reagierst, mit Reaktion und Schweregrad.", + "description": "Eine Allergie oder Unverträglichkeit erfassen.", + "add": "Allergie hinzufügen", + "addFirst": "Erste Allergie hinzufügen", + "addTitle": "Allergie hinzufügen", + "editTitle": "Allergie bearbeiten", + "formDescription": "Der Stoff wird im Klartext gespeichert; Reaktion und Notiz werden verschlüsselt.", + "emptyTitle": "Keine Allergien erfasst", + "emptyDescription": "Erfasse Stoffe, auf die du reagierst, und die Reaktionen.", + "loadError": "Deine Allergien konnten nicht geladen werden.", + "deletedToast": "Allergie gelöscht", + "deleteError": "Die Allergie konnte nicht gelöscht werden.", + "deleteConfirmTitle": "Diese Allergie löschen?", + "deleteConfirmDescription": "Damit wird der Eintrag entfernt. Das lässt sich nicht rückgängig machen.", + "category": { + "FOOD": "Nahrungsmittel", + "MEDICATION": "Medikament", + "ENVIRONMENT": "Umwelt", + "BIOLOGIC": "Biologikum", + "OTHER": "Sonstiges" + }, + "type": { + "ALLERGY": "Allergie", + "INTOLERANCE": "Unverträglichkeit" + }, + "severity": { + "NONE": "Nicht bewertet", + "MILD": "Leicht", + "MODERATE": "Mittel", + "SEVERE": "Schwer" + }, + "status": { + "ACTIVE": "Aktiv", + "INACTIVE": "Inaktiv", + "RESOLVED": "Abgeklungen" + }, + "form": { + "substance": "Stoff", + "substancePlaceholder": "z. B. Penicillin, Erdnüsse", + "category": "Kategorie", + "type": "Art", + "severity": "Schweregrad", + "status": "Status", + "onset": "Erstmals bemerkt", + "reaction": "Reaktion", + "reactionPlaceholder": "z. B. Nesselsucht, Schwellung, Anaphylaxie", + "note": "Notiz", + "notePlaceholder": "Was du dir sonst merken möchtest", + "requiredError": "Bitte den Stoff angeben.", + "savedToast": "Allergie gespeichert", + "updatedToast": "Allergie aktualisiert", + "saveError": "Die Allergie konnte nicht gespeichert werden." + } + }, + "family": { + "cardTitle": "Familienanamnese", + "cardDescription": "Erkrankungen bei Verwandten, ein Eintrag je Erkrankung.", + "description": "Eine Erkrankung bei einem Verwandten erfassen.", + "add": "Eintrag hinzufügen", + "addFirst": "Ersten Eintrag hinzufügen", + "addTitle": "Familienanamnese hinzufügen", + "editTitle": "Familienanamnese bearbeiten", + "formDescription": "Verwandtschaft und Erkrankung werden im Klartext gespeichert; die Notiz wird verschlüsselt.", + "emptyTitle": "Keine Familienanamnese erfasst", + "emptyDescription": "Erfasse Erkrankungen, die in deiner Familie vorkommen.", + "loadError": "Deine Familienanamnese konnte nicht geladen werden.", + "deletedToast": "Eintrag gelöscht", + "deleteError": "Der Eintrag konnte nicht gelöscht werden.", + "deleteConfirmTitle": "Diesen Eintrag löschen?", + "deleteConfirmDescription": "Damit wird der Eintrag entfernt. Das lässt sich nicht rückgängig machen.", + "ageAtOnsetShort": "Alter {age}", + "relationship": { + "MOTHER": "Mutter", + "FATHER": "Vater", + "SISTER": "Schwester", + "BROTHER": "Bruder", + "DAUGHTER": "Tochter", + "SON": "Sohn", + "GRANDMOTHER_MATERNAL": "Großmutter (mütterlich)", + "GRANDFATHER_MATERNAL": "Großvater (mütterlich)", + "GRANDMOTHER_PATERNAL": "Großmutter (väterlich)", + "GRANDFATHER_PATERNAL": "Großvater (väterlich)", + "AUNT": "Tante", + "UNCLE": "Onkel", + "COUSIN": "Cousin/Cousine", + "HALF_SIBLING": "Halbgeschwister", + "OTHER": "Andere/r Verwandte/r" + }, + "form": { + "relationship": "Verwandte/r", + "condition": "Erkrankung", + "conditionPlaceholder": "z. B. Typ-2-Diabetes, Brustkrebs", + "ageAtOnset": "Alter bei Beginn", + "ageAtOnsetPlaceholder": "z. B. 55", + "note": "Notiz", + "notePlaceholder": "Was du dir sonst merken möchtest", + "requiredError": "Bitte die Erkrankung angeben.", + "ageError": "Bitte ein Alter zwischen 0 und 120 angeben.", + "savedToast": "Eintrag gespeichert", + "updatedToast": "Eintrag aktualisiert", + "saveError": "Der Eintrag konnte nicht gespeichert werden." + } + } + }, + "environment": { + "fields": { + "tempMean": "Tagestemperatur", + "tempMin": "Nachttiefstwert", + "sunshine": "Sonnenschein", + "daylight": "Tageslicht", + "precip": "Niederschlag", + "pressureMean": "Luftdruck", + "pressureDelta": "Druckschwankung" + } + }, + "documents": { + "title": "Eingehende Dokumente", + "subtitle": "Lade ein klinisches Dokument hoch und prüfe die Angaben, die wir daraus gelesen haben.", + "disclaimer": { + "title": "Wir lesen, wir deuten nicht", + "body": "Wir übernehmen nur das, was dein Dokument angibt — Werte, Codes und Daten — und stellen keine Diagnose, deuten nichts und bewerten nichts. Nichts wird gespeichert, bevor du es geprüft und bestätigt hast. Nur deine Ärztin oder dein Arzt kann diese Ergebnisse beurteilen." + }, + "upload": { + "label": "Dokument hochladen", + "kindLabel": "Dokumenttyp", + "button": "Hochladen", + "uploading": "Wird gelesen …", + "fileLabel": "Dokumentdatei" + }, + "kind": { + "DOCTOR_REPORT": "Arztbericht", + "DISCHARGE_LETTER": "Entlassbrief", + "OTHER": "Sonstiges" + }, + "list": { + "empty": "Noch keine Dokumente." + }, + "status": { + "EXTRACTING": "Wird gelesen", + "EXTRACTED": "Bereit zur Prüfung", + "FAILED": "Konnte nicht gelesen werden", + "CONFIRMED": "Geprüft", + "DISCARDED": "Verworfen" + }, + "review": { + "title": "Erkannte Angaben prüfen", + "discard": "Verwerfen", + "confirm": "Bestätigte Angaben speichern", + "confirming": "Wird gespeichert …", + "needsReview": "Prüfung nötig", + "approve": "Übernehmen", + "reject": "Ablehnen", + "edit": "Bearbeiten", + "cancel": "Abbrechen", + "save": "Speichern" + }, + "empty": { + "noFacts": "Nichts mehr zu prüfen." + }, + "factType": { + "CONDITION": "Diagnose", + "OBSERVATION": "Wert", + "MEDICATION_STATEMENT": "Medikament" + }, + "fields": { + "label": "Name", + "value": "Wert", + "unit": "Einheit", + "date": "Datum", + "dose": "Dosis" + }, + "toast": { + "uploaded": "Dokument gelesen. Prüfe die Angaben unten.", + "uploadFailed": "Dokument konnte nicht gelesen werden.", + "confirmed": "Bestätigte Angaben gespeichert.", + "confirmFailed": "Deine Entscheidungen konnten nicht gespeichert werden.", + "saved": "Angabe aktualisiert.", + "discarded": "Dokument verworfen." + } + }, + "selfDescription": { + "intendedPurpose": "HealthLog ist ein persönliches Werkzeug zur Gesundheitsdokumentation und zum Wohlbefinden. Es hilft dir, deine eigenen Gesundheitsdaten zu erfassen, zu visualisieren und zu exportieren. Es diagnostiziert, behandelt, heilt oder verhindert keine Krankheit und ist kein Medizinprodukt.", + "coach": "Der Coach arbeitet mit deinen eigenen erfassten Daten. Er ist kein Arzt und gibt keine medizinische Beratung — für Diagnose oder Behandlung wende dich an medizinisches Fachpersonal.", + "insights": "Das sind beschreibende Muster aus deinen eigenen erfassten Daten, keine klinische Beurteilung und keine Diagnose.", + "labs": "Das sind deine eigenen Aufzeichnungen, keine medizinische Diagnose. Referenzbereiche unterscheiden sich je nach Labor und Person — nur deine Ärztin oder dein Arzt kann sie für dich einordnen.", + "medications": "Das erfasst die von dir dokumentierten Medikamente und deine eigene Einnahmetreue. Es beurteilt weder Dosierung noch Wechselwirkungen und ist keine medizinische Beratung — kläre das mit Apotheke oder Arztpraxis.", + "emergency": "Falls das ein Notfall sein könnte, wende dich jetzt an deine örtliche Notrufnummer oder an eine ärztliche Stelle. HealthLog kann im Notfall nicht helfen.", + "dataPosture": "HealthLog wird selbst gehostet: Deine Gesundheitsdaten bleiben auf deiner eigenen Instanz und sind im Ruhezustand verschlüsselt. KI-Funktionen nutzen einen von dir mitgebrachten Anbieter-Schlüssel oder ein lokales Modell — nichts verlässt deine Instanz über den von dir konfigurierten Anbieter hinaus, und es gibt keine Drittanbieter-Analyse deiner Gesundheitsdaten." } } diff --git a/messages/en.json b/messages/en.json index 14fa8b085..511493b1c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -35,7 +35,8 @@ "showPassword": "Show password", "hidePassword": "Hide password", "learnMore": "Learn more", - "dismiss": "Dismiss" + "dismiss": "Dismiss", + "moreInfo": "More information" }, "doctorReport": { "title": "Health Report", @@ -238,7 +239,9 @@ "cycle": "Cycle", "labs": "Labs", "vorsorge": "Checkups", - "illness": "Illness" + "illness": "Illness", + "documents": "Documents", + "mentalWellbeing": "Mental wellbeing" }, "auth": { "login": "Sign in", @@ -574,7 +577,15 @@ "bulkDeleteSuccess": "{count} measurements deleted", "bulkDeleteError": "Error deleting the selected measurements", "bulkDeleteConfirmTitle": "Delete {count} measurements?", - "bulkDeleteConfirmBody": "The {count} selected measurements will be permanently deleted." + "bulkDeleteConfirmBody": "The {count} selected measurements will be permanently deleted.", + "typePhq9Score": "PHQ-9 score", + "typeGad7Score": "GAD-7 score", + "typeGripStrength": "Grip strength", + "typePainNrs": "Pain (0–10)", + "typeWaistCircumference": "Waist circumference", + "typeWaistToHeight": "Waist-to-height ratio", + "unitScore": "pts", + "unitRatio": "ratio" }, "mood": { "title": "Mood", @@ -750,7 +761,8 @@ "sectionFactors": "Factors", "sectionNoteDetails": "Note & details", "quickTagsHelp": "Your frequent tags — one tap", - "quickTagsExpand": "More" + "quickTagsExpand": "More", + "loadError": "Could not load your mood entries." }, "medications": { "title": "Medications", @@ -1852,7 +1864,20 @@ "sat": "Saturday", "sun": "Sunday" }, - "rangeBand": "Range" + "rangeBand": "Range", + "a11y": { + "summary": "{metric} chart. Range {min} to {max}, latest {latest}, trend {trend}.", + "noData": "{metric} chart. No data available.", + "trendUp": "rising", + "trendDown": "falling", + "trendFlat": "steady", + "scatterSummary": "Correlation chart, {count} points plotting {xLabel} against {yLabel}.", + "scatterNoData": "Correlation chart of {xLabel} against {yLabel}. No data available.", + "moodHeatmap": "Mood heatmap from {start} to {end}, {days} days logged.", + "moodHeatmapEmpty": "Mood heatmap, no days logged yet.", + "complianceHeatmap": "Medication compliance heatmap from {start} to {end}, {days} active days.", + "complianceHeatmapEmpty": "Medication compliance heatmap, no active days yet." + } }, "chart": { "overlay": { @@ -2037,7 +2062,11 @@ "noProviderTitle": "No AI provider connected", "noProviderDescription": "The daily briefing turns your readings into plain-language summary. HealthLog needs an AI provider for that — use your own key (OpenAI, Anthropic) or a local model. Your data stays with you.", "noProviderAction": "Open AI settings", - "staleNoProviderHint": "This briefing can't refresh — no AI provider is connected." + "staleNoProviderHint": "This briefing can't refresh — no AI provider is connected.", + "refreshFailedHint": "Couldn't refresh the briefing — showing the last one.", + "retryAction": "Retry", + "failedTitle": "Couldn't generate the briefing", + "failedDescription": "The AI provider timed out or returned an error. Try again, or raise the response timeout in AI settings for a slow local model." }, "trendsRow": { "title": "Trends", @@ -2372,7 +2401,8 @@ "correlationMoodPulse": "Is there a real link between my mood and my pulse? Help me read this correlation.", "correlationWeightWeekday": "Does my weight follow a weekly pattern? What does this day-of-week correlation tell me?", "recommendation": "Tell me more about this recommendation: \"{text}\" — why does it apply to me, and what should I do about it?" - } + }, + "assessmentPrompt": "Walk me through my {metric} assessment — what does it mean and what should I do about it?" }, "relativeJustNow": "just now", "relativeMinutesAgoOne": "{count} minute ago", @@ -2444,7 +2474,8 @@ "emptyState": { "title": "No resting heart rate yet", "description": "Resting heart rate arrives once Apple Watch or a compatible Withings device has worn through one quiet day." - } + }, + "coachPrefill": "I haven't logged any resting heart rate yet — why is the daily resting value different from a spot pulse, and how do I improve it?" }, "oxygenSaturation": { "title": "Oxygen saturation", @@ -2670,7 +2701,8 @@ "caption": "Cumulative shortfall over the last {nights} nights", "clearValue": "All caught up", "clearCaption": "No accumulated shortfall over the last {nights} nights", - "learning": "Still learning your sleep — {counted} nights so far, {remaining} more to read a reliable balance." + "learning": "Still learning your sleep — {counted} nights so far, {remaining} more to read a reliable balance.", + "computedInfo": "This is HealthLog's own estimate: a rolling balance of need minus sleep over recent nights, with catch-up sleep paying it down. It can differ from a wearable's native sleep-debt figure, which uses its own need model." }, "average": { "title": "Average per night", @@ -3585,28 +3617,7 @@ }, "sleepQuality": { "title": "Sleep quality", - "subtitle": "Nightly scores from your wearable", - "assessment": { - "metric": { - "SLEEP_SCORE": "your overall sleep score", - "SLEEP_EFFICIENCY": "sleep efficiency", - "SLEEP_PERFORMANCE": "sleep performance", - "SLEEP_CONSISTENCY": "sleep consistency" - }, - "grade": { - "excellent": "excellent", - "good": "solid", - "fair": "fair", - "low": "on the low side" - }, - "clause": "Over the last 30 nights {metric} is {grade} at {value}.", - "closing": { - "excellent": "Keep the routine that is getting you here.", - "good": "A steadier bed and wake time is the usual next gain.", - "fair": "Protecting a consistent sleep window tends to lift this most.", - "low": "A regular wind-down and a fixed wake time are the first levers to pull." - } - } + "subtitle": "Nightly scores from your wearable" }, "recovery": { "title": "Recovery", @@ -3666,7 +3677,86 @@ }, "updatedTodayAt": "Updated today, {time}", "updatedYesterday": "Updated yesterday", - "updatedOn": "Updated on {date}" + "updatedOn": "Updated on {date}", + "healthStatus": { + "sectionTitle": "What's changed", + "subtitle": "Signals drifting from your usual range.", + "deviationAbove": "{vital} is above your typical range.", + "deviationBelow": "{vital} is below your typical range.", + "shiftUp": "{vital} has shifted up from your recent normal.", + "shiftDown": "{vital} has shifted down from your recent normal.", + "rangeContext": "Now {value}, usually {low}–{high}.", + "shiftContext": "Around {before} before, {after} since." + }, + "breathingScreening": { + "sectionTitle": "Breathing during sleep", + "subtitle": "A screening signal from your sleep data.", + "classificationElevated": "Your device flagged elevated breathing disturbances.", + "classificationNotElevated": "Your device's classification is not elevated.", + "trendUp": "Trending higher than your earlier nights.", + "trendDown": "Trending lower than your earlier nights.", + "trendStable": "Holding steady against your earlier nights.", + "nights": "{count} nights tracked", + "indexLine": "recent average index {value}", + "eventsFlagged": "{count} events flagged by your device", + "disclaimer": "This is a screening signal, not a diagnosis — discuss with a clinician." + }, + "labsChanges": { + "sectionTitle": "Since your last panel", + "subtitle": "How your latest results compare with the panel before.", + "statusInRange": "in range", + "statusBelow": "below range", + "statusAbove": "above range", + "directionFlat": "no change" + }, + "navGripStrength": "Grip strength", + "navPainNrs": "Pain", + "navWaistCircumference": "Waist", + "navWaistToHeight": "Waist-to-height", + "gripStrength": { + "title": "Grip strength", + "description": "Maximal handgrip force — a robust proxy for whole-body strength. Below the sex-specific cut-off (men 27 kg, women 16 kg) flags probable sarcopenia; your own trend leads the read.", + "chartTitle": "Grip strength", + "emptyState": { + "title": "No grip strength yet", + "description": "Log a reading from a hand dynamometer — record the best of a few squeezes — and the trend will appear here.", + "cta": "Log grip strength" + }, + "coachPrefill": "I haven't logged any grip strength yet — what does it tell me about my muscle health, and how do I measure and improve it?" + }, + "pain": { + "title": "Pain (0–10)", + "description": "Patient-reported pain intensity on the 0–10 numeric rating scale, where 0 is no pain and 10 is the worst imaginable. A sustained 2-point change is the signal to watch.", + "chartTitle": "Pain (0–10)", + "emptyState": { + "title": "No pain scores yet", + "description": "Log how your pain feels right now on the 0–10 scale and the trend will appear here.", + "cta": "Log a pain score" + }, + "coachPrefill": "I haven't logged any pain scores yet — how should I track pain on the 0–10 scale, and what counts as a meaningful change?" + }, + "waist": { + "title": "Waist circumference", + "description": "Waist girth in centimetres — a marker of central fat. The WHO increased-risk threshold is sex-specific (men 94 cm, women 80 cm); your own trend leads the read.", + "chartTitle": "Waist circumference", + "emptyState": { + "title": "No waist measurements yet", + "description": "Measure your waist at the midpoint between the lowest rib and the hip bone, at the end of a normal breath, and log it here.", + "cta": "Log waist circumference" + }, + "coachPrefill": "I haven't logged any waist measurements yet — how do I measure my waist correctly, and what does the number mean for my health?" + }, + "waistToHeight": { + "title": "Waist-to-height ratio", + "description": "Your waist divided by your height — the ancestry-robust single number. Keeping it under 0.5 (waist below half your height) is the simple target.", + "chartTitle": "Waist-to-height ratio", + "emptyState": { + "title": "No waist-to-height ratio yet", + "description": "Log a waist-to-height ratio and the trend will appear here. The rule of thumb: keep your waist under half your height.", + "cta": "Log waist-to-height ratio" + }, + "coachPrefill": "I haven't logged a waist-to-height ratio yet — why is it a useful number, and how do I keep it under 0.5?" + } }, "thresholds": { "defaultLabel": "Default", @@ -4012,7 +4102,8 @@ "skinTemperature": "Skin temperature", "respiratoryRate": "Respiratory rate", "recovery": "Recovery", - "stress": "Stress" + "stress": "Stress", + "sleepDebt": "Sleep debt" }, "sourceLabels": { "WITHINGS": "Withings", @@ -4235,6 +4326,50 @@ "mcp": { "title": "MCP Connector", "subtitle": "Connect an external assistant to your own health data." + }, + "anamnesis": { + "title": "Medical history", + "subtitle": "Manage your allergies and family history.", + "description": "Structured allergies and family history records." + }, + "environment": { + "title": "Environment", + "subtitle": "Weather, daylight, and location context.", + "disabledHint": "Turn on the Environment module to set a home location and pull in daily weather and daylight.", + "searchPlaceholder": "Search for a city", + "search": "Search", + "searchError": "Could not search for that place.", + "use": "Use", + "select": "Select", + "saveError": "Could not save your change.", + "homeSaved": "Home location saved.", + "travelAdded": "Location period added.", + "travelRemoved": "Location period removed.", + "backfillQueued": "Backfill queued.", + "backfillError": "Could not start the backfill.", + "startDate": "Start date", + "endDate": "End date", + "storedDays": "{count} days of weather stored.", + "home": { + "title": "Home location", + "description": "A coarse, city-level location — never a precise track. Weather and daylight are fetched for this place on days no location period applies, from the date you set it onward.", + "current": "Current: {label}", + "none": "No home location set yet.", + "since": "Weather tracked from {date} onward. Earlier days stay empty until you add a location period." + }, + "travel": { + "title": "Location periods", + "description": "Add a place and a date range — a trip, or a stretch of earlier history. The period overrides your home for the days it covers, so weather matches where you actually were. This is how you correct the past.", + "none": "No location periods yet.", + "add": "Add {label}", + "remove": "Remove location period" + }, + "backfill": { + "title": "Backfill past days", + "description": "Fetch and store weather for a past date range so older days join the correlations. The start defaults to your home's effective date; to reach further back, add a location period for that range first.", + "run": "Backfill" + }, + "noResults": "No matching places found." } }, "profile": "Profile", @@ -6076,7 +6211,13 @@ "frequencyAria": "Select nudge frequency", "enabledToast": "Proactive Coach nudges enabled.", "disabledToast": "Proactive Coach nudges disabled.", - "saveError": "Could not save. Please try again later." + "saveError": "Could not save. Please try again later.", + "suggestionsLabel": "Example suggestions", + "suggestionsDesc": "Show the occasional ready-made example question on the Coach screen.", + "suggestionsAria": "Toggle example Coach suggestions", + "aiComposedLabel": "Let the Coach phrase the nudge", + "aiComposedDesc": "When a provider is available, the proactive nudge is written by the model instead of the fixed template. Off by default.", + "aiComposedAria": "Toggle AI-composed proactive nudge" }, "lowStock": { "title": "Low-stock alert", @@ -6173,7 +6314,9 @@ "resetToDefault": "Reset to default", "saveSuccess": "Target range updated", "saveError": "Could not save the target range", - "resetSuccess": "Target range reset to default" + "resetSuccess": "Target range reset to default", + "validationOrder": "The minimum must be lower than the maximum.", + "validationBounds": "Enter values within the allowed range shown below." }, "currentValue": "Current: {value} {unit}", "targetRangeValue": "Target: {min}–{max} {unit}", @@ -6305,21 +6448,18 @@ "dailyBody": "How are you feeling today?" }, "coachNudges": { - "complianceTitle": "Your Coach has a thought on this", - "complianceBody": "Your intakes were patchy this week. Have a quick word with the Coach — maybe the plan can be simplified.", - "bpTitle": "Blood pressure elevated this week", - "bpBody": "Your blood pressure averaged above your target this week. The Coach can help you put the readings in context.", - "scoreTitle": "Your recovery score is dropping", - "scoreBody": "Your recovery score fell noticeably this week. The Coach can help you narrow down possible causes.", - "selfContextTitle": "Help your Coach know you better", - "selfContextBody": "Your self-description is incomplete or has not been updated in a while. Two minutes in the settings keep the Coach's context fresh.", - "weightTitle": "Your weight is drifting from your target", - "weightBody": "Your weekly average moved further away from your target range. The Coach can help you take stock calmly.", - "sleepDebtTitle": "Several short nights this week", - "sleepDebtBody": "Most of your recent nights came in under your sleep target. The Coach can help you look for patterns.", - "measurementGapTitle": "Your log went quiet", - "measurementGapBody": "No new readings arrived for a week. A quick word with the Coach helps you pick the routine back up — or spot a broken sync.", - "focusSuffix": "You wanted to keep an eye on: {focus}.", + "greetingMorning": "Good morning", + "greetingMorningNamed": "Good morning, {name}", + "greetingHey": "Hey there", + "greetingHeyNamed": "Hey {name}", + "complianceBody": "Your medication routine slipped a little this week. If you like, we can look at what would make it easier to keep up.", + "bpBody": "Your blood pressure has been running a touch above your usual range this week. Whenever you're ready, I'm happy to look at it with you.", + "scoreBody": "Your recovery has been easing off over the last few days. If you're curious what's behind it, I'm here to talk it through.", + "selfContextBody": "It's been a while since your self-description was updated. When you have a moment, refreshing it helps me understand you better.", + "weightBody": "Your weight has drifted a little from your target range lately. If you'd like, we can take stock together, calmly.", + "sleepDebtBody": "Several of your nights came up short this week. If you want, we can look for a pattern together.", + "measurementGapBody": "Your log has been quiet for about a week. Whenever it suits you, a quick check-in helps you pick things back up.", + "focusNote": "It also ties into what you said you wanted to keep an eye on.", "questionConditions": "Do you have any chronic conditions the Coach should know about?", "questionFocus": "Is there anything specific the Coach should pay attention to?", "questionAllergies": "Do you have any allergies or intolerances?", @@ -6358,6 +6498,11 @@ "label": "Passphrase", "error": "That passphrase is not correct.", "submit": "Unlock" + }, + "loadError": { + "title": "This shared view could not be loaded", + "description": "Something went wrong while opening this shared health record. The link may have expired, or there was a temporary problem. Try again, or ask the person who shared it for a new link.", + "retry": "Try again" } }, "cycle": { @@ -6831,7 +6976,8 @@ "empty": "No upcoming checkup reminders.", "openLink": "Open checkups" }, - "showMeasurements": "Show measurements" + "showMeasurements": "Show measurements", + "loadError": "Could not load your reminders." }, "moduleList": { "viewToggleLabel": "View", @@ -6942,7 +7088,7 @@ "deletedToast": "Biomarker deleted", "deleteError": "Could not delete the biomarker", "deleteConfirmTitle": "Delete biomarker?", - "deleteConfirmDescription": "The definition is removed. Existing readings are kept but unlinked.", + "deleteConfirmDescription": "The biomarker and all of its recorded values are permanently deleted. This cannot be undone.", "emptyTitle": "No biomarkers yet", "emptyDescription": "Define markers like LDL or HbA1c to record values against them.", "loadError": "Could not load biomarkers", @@ -6977,7 +7123,14 @@ "unhiddenToast": "Biomarker restored", "hideError": "Could not update the biomarker", "allHidden": "Every biomarker is hidden.", - "hiddenSection": "Hidden" + "hiddenSection": "Hidden", + "targetRange": { + "title": "Adjust target range", + "description": "Set the reference low and high for this marker. Leave a field empty for an open-ended bound.", + "save": "Save range", + "savedToast": "Target range updated", + "saveError": "Could not update the target range" + } }, "catalog": { "total-cholesterol": "Total cholesterol", @@ -7044,8 +7197,16 @@ "hemoglobin": "The oxygen-carrying protein in red blood cells.", "hematocrit": "The fraction of blood volume made up of red blood cells.", "wbc": "Total count of white blood cells, the immune system's circulating defence.", - "platelets": "Cell fragments that clump together to form blood clots." - } + "platelets": "Cell fragments that clump together to form blood clots.", + "apob": "Counts atherogenic particles directly; often a better risk marker than LDL-C when the two disagree.", + "lp-a": "A largely inherited, lifelong risk factor — usually measured once. Report molar units (nmol/L).", + "fasting-insulin": "Pairs with fasting glucose to estimate insulin resistance (HOMA-IR).", + "omega-3-index": "Red-blood-cell EPA+DHA share; 8–12% is the commonly cited cardioprotective range." + }, + "apob": "Apolipoprotein B (ApoB)", + "lp-a": "Lipoprotein(a)", + "fasting-insulin": "Fasting insulin", + "omega-3-index": "Omega-3 index" }, "noteLoadFailed": "Note could not be loaded; it will be kept unchanged when you save.", "showingLatestOf": "Showing the latest {shown} of {total} readings", @@ -7092,7 +7253,8 @@ "fileType": "Upload a JPEG, PNG, WebP, or PDF.", "extractFailed": "Couldn't read the report. Try a clearer photo.", "pdfNeedsAnthropic": "PDF scanning needs a Claude vision provider; upload a photo instead." - } + }, + "loadMoreReadings": "Load more readings" }, "modules": { "cycle": { @@ -7150,6 +7312,18 @@ "mcp": { "label": "External assistant access (MCP)", "description": "Let an MCP-capable assistant read your health data over a scoped token. Off by default." + }, + "environment": { + "label": "Environment", + "description": "Pull in daily weather and daylight for your location to correlate with mood, sleep, and vitals. Off by default." + }, + "inboundDocuments": { + "label": "Inbound documents", + "description": "Upload a doctor report or discharge letter; review the extracted facts before they're saved. Off by default." + }, + "mentalHealth": { + "label": "Mental wellbeing", + "description": "Opt-in PHQ-9 and GAD-7 screeners beside mood tracking. Off by default; answers are encrypted and kept out of the AI Coach." } }, "illness": { @@ -7312,7 +7486,8 @@ "BLOOD_PRESSURE_DIA": "Diastolic blood pressure", "PULSE": "Pulse", "WEIGHT": "Weight" - } + }, + "listLoadError": "Could not load your episodes." }, "coach": { "reminderSuggestion": { @@ -7372,5 +7547,318 @@ "glucoseHighDoctor": "Two recent readings were {value} mg/dL or higher. Re-check, hydrate, and contact your doctor as soon as possible if it stays this high.", "glucoseHighEmergency": "Two recent readings were {value} mg/dL or higher and you noted symptoms. If you have vomiting, fast breathing, or confusion, call emergency services now." } + }, + "version": { + "updateAvailable": { + "title": "A new version is available", + "reload": "Reload", + "dismiss": "Dismiss" + } + }, + "mentalHealth": { + "pageTitle": "Mental wellbeing check-in", + "pageDescription": "Two validated, opt-in self-assessments — PHQ-9 (mood) and GAD-7 (worry) — that sit beside your mood tracking. The value is the trend over time, not any single number.", + "disclaimer": "This is a screening self-assessment, not a diagnosis. A high or low score does not mean you do or do not have a condition. Only a qualified health professional can diagnose. If you have concerns, please talk to your doctor.", + "optInNote": "Opt-in and private: your individual answers are encrypted, kept out of the AI Coach and exports, and never shared.", + "attributionLabel": "Attribution", + "choosePrompt": "Choose a check-in", + "start": "Start", + "back": "Back", + "stem": "Over the last 2 weeks, how often have you been bothered by the following problems?", + "submit": "See my result", + "submitting": "Saving…", + "saved": "Saved", + "error": "Something went wrong. Please try again.", + "considerProfessional": "Your score is in a range where talking to a health professional may help. This is a gentle suggestion, not a diagnosis.", + "instrument": { + "phq9": "PHQ-9 — mood", + "gad7": "GAD-7 — worry" + }, + "instrumentSub": { + "phq9": "Depression screen, 9 questions", + "gad7": "Anxiety screen, 7 questions" + }, + "options": { + "0": "Not at all", + "1": "Several days", + "2": "More than half the days", + "3": "Nearly every day" + }, + "functionalTitle": "If you checked off any problems, how difficult have these made it to do your work, take care of things at home, or get along with other people?", + "functional": { + "0": "Not difficult at all", + "1": "Somewhat difficult", + "2": "Very difficult", + "3": "Extremely difficult" + }, + "result": { + "title": "Your result", + "totalLabel": "Total score", + "bandLabel": "Range for this screen", + "takeAnother": "Take another check-in" + }, + "band": { + "PHQ9": { + "minimal": "None / minimal", + "mild": "Mild", + "moderate": "Moderate", + "modSevere": "Moderately severe", + "severe": "Severe" + }, + "GAD7": { + "minimal": "None / minimal", + "mild": "Mild", + "moderate": "Moderate", + "severe": "Severe" + } + }, + "history": { + "title": "Your history", + "empty": "No check-ins yet. Your trend will appear here once you complete one." + }, + "crisis": { + "title": "You don't have to face this alone", + "intro": "You indicated thoughts of being better off dead or of hurting yourself. Support is available right now, and reaching out is a sign of strength.", + "ifDanger": "If you are in immediate danger, call {emergency} now.", + "resourcesTitle": "People who can help" + }, + "items": { + "phq9": { + "1": "Little interest or pleasure in doing things", + "2": "Feeling down, depressed, or hopeless", + "3": "Trouble falling or staying asleep, or sleeping too much", + "4": "Feeling tired or having little energy", + "5": "Poor appetite or overeating", + "6": "Feeling bad about yourself — or that you are a failure or have let yourself or your family down", + "7": "Trouble concentrating on things, such as reading the newspaper or watching television", + "8": "Moving or speaking so slowly that other people could have noticed. Or the opposite — being so fidgety or restless that you have been moving around a lot more than usual", + "9": "Thoughts that you would be better off dead, or of hurting yourself in some way" + }, + "gad7": { + "1": "Feeling nervous, anxious, or on edge", + "2": "Not being able to stop or control worrying", + "3": "Worrying too much about different things", + "4": "Trouble relaxing", + "5": "Being so restless that it is hard to sit still", + "6": "Becoming easily annoyed or irritable", + "7": "Feeling afraid as if something awful might happen" + } + }, + "crisisResource": { + "telefonSeelsorge": { + "name": "TelefonSeelsorge (24/7, free, confidential)" + }, + "nummerGegenKummer": { + "name": "Nummer gegen Kummer (for young people)" + }, + "krisenchat": { + "name": "krisenchat (24/7 chat, young people)" + }, + "findahelpline": { + "name": "Find a helpline (worldwide directory)" + }, + "euEmotionalSupport": { + "name": "Emotional-support helpline" + }, + "lifeline988": { + "name": "988 Suicide & Crisis Lifeline" + }, + "crisisTextLine": { + "name": "Crisis Text Line" + } + } + }, + "records": { + "disclaimer": "These are your own records, not a medical diagnosis. HealthLog does not diagnose, treat, or interpret them.", + "allergies": { + "cardTitle": "Allergies & intolerances", + "cardDescription": "Substances you react to, with the reaction and how severe it is.", + "description": "Record an allergy or intolerance.", + "add": "Add allergy", + "addFirst": "Add your first allergy", + "addTitle": "Add allergy", + "editTitle": "Edit allergy", + "formDescription": "The substance is stored as plain text; the reaction and note are encrypted.", + "emptyTitle": "No allergies recorded", + "emptyDescription": "Add substances you react to and their reactions.", + "loadError": "Couldn't load your allergies.", + "deletedToast": "Allergy deleted", + "deleteError": "Couldn't delete the allergy.", + "deleteConfirmTitle": "Delete this allergy?", + "deleteConfirmDescription": "This removes the record. It can't be undone.", + "category": { + "FOOD": "Food", + "MEDICATION": "Medication", + "ENVIRONMENT": "Environment", + "BIOLOGIC": "Biologic", + "OTHER": "Other" + }, + "type": { + "ALLERGY": "Allergy", + "INTOLERANCE": "Intolerance" + }, + "severity": { + "NONE": "Not assessed", + "MILD": "Mild", + "MODERATE": "Moderate", + "SEVERE": "Severe" + }, + "status": { + "ACTIVE": "Active", + "INACTIVE": "Inactive", + "RESOLVED": "Resolved" + }, + "form": { + "substance": "Substance", + "substancePlaceholder": "e.g. Penicillin, Peanuts", + "category": "Category", + "type": "Type", + "severity": "Severity", + "status": "Status", + "onset": "First noticed", + "reaction": "Reaction", + "reactionPlaceholder": "e.g. hives, swelling, anaphylaxis", + "note": "Note", + "notePlaceholder": "Anything else worth remembering", + "requiredError": "Enter the substance.", + "savedToast": "Allergy saved", + "updatedToast": "Allergy updated", + "saveError": "Couldn't save the allergy." + } + }, + "family": { + "cardTitle": "Family history", + "cardDescription": "Conditions in your relatives, one entry per condition.", + "description": "Record a condition in a relative.", + "add": "Add entry", + "addFirst": "Add your first entry", + "addTitle": "Add family history", + "editTitle": "Edit family history", + "formDescription": "The relationship and condition are stored as plain text; the note is encrypted.", + "emptyTitle": "No family history recorded", + "emptyDescription": "Add conditions that run in your family.", + "loadError": "Couldn't load your family history.", + "deletedToast": "Entry deleted", + "deleteError": "Couldn't delete the entry.", + "deleteConfirmTitle": "Delete this entry?", + "deleteConfirmDescription": "This removes the record. It can't be undone.", + "ageAtOnsetShort": "Age {age}", + "relationship": { + "MOTHER": "Mother", + "FATHER": "Father", + "SISTER": "Sister", + "BROTHER": "Brother", + "DAUGHTER": "Daughter", + "SON": "Son", + "GRANDMOTHER_MATERNAL": "Grandmother (maternal)", + "GRANDFATHER_MATERNAL": "Grandfather (maternal)", + "GRANDMOTHER_PATERNAL": "Grandmother (paternal)", + "GRANDFATHER_PATERNAL": "Grandfather (paternal)", + "AUNT": "Aunt", + "UNCLE": "Uncle", + "COUSIN": "Cousin", + "HALF_SIBLING": "Half-sibling", + "OTHER": "Other relative" + }, + "form": { + "relationship": "Relative", + "condition": "Condition", + "conditionPlaceholder": "e.g. Type 2 diabetes, Breast cancer", + "ageAtOnset": "Age at onset", + "ageAtOnsetPlaceholder": "e.g. 55", + "note": "Note", + "notePlaceholder": "Anything else worth remembering", + "requiredError": "Enter the condition.", + "ageError": "Enter an age between 0 and 120.", + "savedToast": "Entry saved", + "updatedToast": "Entry updated", + "saveError": "Couldn't save the entry." + } + } + }, + "environment": { + "fields": { + "tempMean": "Daily temperature", + "tempMin": "Overnight low", + "sunshine": "Sunshine", + "daylight": "Daylight", + "precip": "Precipitation", + "pressureMean": "Barometric pressure", + "pressureDelta": "Pressure swing" + } + }, + "documents": { + "title": "Inbound documents", + "subtitle": "Upload a clinical document and review the facts we read from it.", + "disclaimer": { + "title": "We read, we don't interpret", + "body": "We only transcribe what your document states — values, codes, and dates — and never diagnose, interpret, or flag anything. Nothing is saved until you review and approve it. Only your clinician can interpret these results." + }, + "upload": { + "label": "Upload a document", + "kindLabel": "Document type", + "button": "Upload", + "uploading": "Reading…", + "fileLabel": "Document file" + }, + "kind": { + "DOCTOR_REPORT": "Doctor report", + "DISCHARGE_LETTER": "Discharge letter", + "OTHER": "Other" + }, + "list": { + "empty": "No documents yet." + }, + "status": { + "EXTRACTING": "Reading", + "EXTRACTED": "Ready to review", + "FAILED": "Couldn't read", + "CONFIRMED": "Reviewed", + "DISCARDED": "Discarded" + }, + "review": { + "title": "Review extracted facts", + "discard": "Discard", + "confirm": "Save approved facts", + "confirming": "Saving…", + "needsReview": "Needs review", + "approve": "Approve", + "reject": "Reject", + "edit": "Edit", + "cancel": "Cancel", + "save": "Save" + }, + "empty": { + "noFacts": "Nothing left to review." + }, + "factType": { + "CONDITION": "Diagnosis", + "OBSERVATION": "Value", + "MEDICATION_STATEMENT": "Medication" + }, + "fields": { + "label": "Name", + "value": "Value", + "unit": "Unit", + "date": "Date", + "dose": "Dose" + }, + "toast": { + "uploaded": "Document read. Review the facts below.", + "uploadFailed": "Couldn't read the document.", + "confirmed": "Approved facts saved.", + "confirmFailed": "Couldn't save your decisions.", + "saved": "Fact updated.", + "discarded": "Document discarded." + } + }, + "selfDescription": { + "intendedPurpose": "HealthLog is a personal health record-keeping and wellness tool. It helps you log, visualise, and export your own health data. It does not diagnose, treat, cure, or prevent any disease, and it is not a medical device.", + "coach": "The Coach works from your own logged data. It isn't a doctor and doesn't give medical advice — for diagnosis or treatment, talk to a healthcare professional.", + "insights": "These are descriptive patterns computed from your own logged data, not a clinical assessment or diagnosis.", + "labs": "These are your own records, not a medical diagnosis. Reference ranges vary by lab and person — only your clinician can interpret them for you.", + "medications": "This tracks the medications you log and your own adherence. It doesn't assess dosing or interactions and isn't medical advice — confirm those with a pharmacist or doctor.", + "emergency": "If this might be an emergency, contact your local emergency number or a clinician now. HealthLog can't help in an emergency.", + "dataPosture": "HealthLog is self-hosted: your health data stays on your own instance, encrypted at rest. AI features use a provider key you bring or a local model, so nothing leaves your instance beyond the provider you configure, and there is no third-party analytics on your health data." } } diff --git a/messages/es.json b/messages/es.json index ce2ee6de9..298329f0c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -35,7 +35,8 @@ "showPassword": "Mostrar contraseña", "hidePassword": "Ocultar contraseña", "learnMore": "Más información", - "dismiss": "Descartar" + "dismiss": "Descartar", + "moreInfo": "Más información" }, "doctorReport": { "title": "Informe de salud", @@ -238,7 +239,9 @@ "cycle": "Ciclo", "labs": "Análisis", "vorsorge": "Revisiones", - "illness": "Enfermedad" + "illness": "Enfermedad", + "documents": "Documentos", + "mentalWellbeing": "Bienestar mental" }, "auth": { "login": "Iniciar sesión", @@ -574,7 +577,15 @@ "bulkDeleteSuccess": "{count} mediciones eliminadas", "bulkDeleteError": "Error al eliminar las mediciones seleccionadas", "bulkDeleteConfirmTitle": "¿Eliminar {count} mediciones?", - "bulkDeleteConfirmBody": "Las {count} mediciones seleccionadas se eliminarán de forma permanente." + "bulkDeleteConfirmBody": "Las {count} mediciones seleccionadas se eliminarán de forma permanente.", + "typePhq9Score": "Puntuación PHQ-9", + "typeGad7Score": "Puntuación GAD-7", + "typeGripStrength": "Fuerza de agarre", + "typePainNrs": "Dolor (0–10)", + "typeWaistCircumference": "Perímetro de cintura", + "typeWaistToHeight": "Índice cintura-altura", + "unitScore": "pts", + "unitRatio": "ratio" }, "mood": { "title": "Estado de ánimo", @@ -750,7 +761,8 @@ "sectionFactors": "Factores", "sectionNoteDetails": "Nota y detalles", "quickTagsHelp": "Tus etiquetas frecuentes: un toque", - "quickTagsExpand": "Más" + "quickTagsExpand": "Más", + "loadError": "No se pudieron cargar tus registros de estado de ánimo." }, "medications": { "title": "Medicamentos", @@ -1852,7 +1864,20 @@ "sat": "Sábado", "sun": "Domingo" }, - "rangeBand": "Rango" + "rangeBand": "Rango", + "a11y": { + "summary": "Gráfico de {metric}. Rango de {min} a {max}, último {latest}, tendencia {trend}.", + "noData": "Gráfico de {metric}. No hay datos disponibles.", + "trendUp": "en aumento", + "trendDown": "en descenso", + "trendFlat": "estable", + "scatterSummary": "Gráfico de correlación, {count} puntos que comparan {xLabel} con {yLabel}.", + "scatterNoData": "Gráfico de correlación de {xLabel} frente a {yLabel}. No hay datos disponibles.", + "moodHeatmap": "Mapa de calor de estado de ánimo de {start} a {end}, {days} días registrados.", + "moodHeatmapEmpty": "Mapa de calor de estado de ánimo, aún no hay días registrados.", + "complianceHeatmap": "Mapa de calor de cumplimiento de medicación de {start} a {end}, {days} días activos.", + "complianceHeatmapEmpty": "Mapa de calor de cumplimiento de medicación, aún no hay días activos." + } }, "chart": { "overlay": { @@ -2037,7 +2062,11 @@ "noProviderTitle": "Ningún proveedor de IA conectado", "noProviderDescription": "El briefing diario resume tus lecturas en lenguaje claro. HealthLog necesita un proveedor de IA para ello: usa tu propia clave (OpenAI, Anthropic) o un modelo local. Tus datos siguen siendo tuyos.", "noProviderAction": "Abrir ajustes de IA", - "staleNoProviderHint": "Este briefing no puede actualizarse: no hay ningún proveedor de IA conectado." + "staleNoProviderHint": "Este briefing no puede actualizarse: no hay ningún proveedor de IA conectado.", + "refreshFailedHint": "No se pudo actualizar el briefing: se muestra el anterior.", + "retryAction": "Reintentar", + "failedTitle": "No se pudo generar el briefing", + "failedDescription": "El proveedor de IA agotó el tiempo de espera o devolvió un error. Inténtalo de nuevo o aumenta el tiempo de espera de respuesta en los ajustes de IA para un modelo local lento." }, "trendsRow": { "title": "Tendencias", @@ -2372,7 +2401,8 @@ "correlationMoodPulse": "¿Existe una relación real entre mi estado de ánimo y mi pulso? Ayúdame a interpretar esta correlación.", "correlationWeightWeekday": "¿Mi peso sigue un patrón semanal? ¿Qué me dice esta correlación por día de la semana?", "recommendation": "Cuéntame más sobre esta recomendación: \"{text}\" — ¿por qué se aplica a mí y qué debería hacer al respecto?" - } + }, + "assessmentPrompt": "Explícame mi evaluación de {metric}: ¿qué significa y qué debería hacer al respecto?" }, "relativeJustNow": "ahora mismo", "relativeMinutesAgoOne": "hace {count} minuto", @@ -2444,7 +2474,8 @@ "emptyState": { "title": "Aún no hay FC en reposo", "description": "La FC en reposo aparece cuando Apple Watch o un Withings compatible ha llevado puesto un día tranquilo." - } + }, + "coachPrefill": "Aún no he registrado ninguna frecuencia cardíaca en reposo: ¿por qué el valor diario en reposo difiere de un pulso puntual y cómo puedo mejorarlo?" }, "oxygenSaturation": { "title": "Saturación de oxígeno", @@ -2670,7 +2701,8 @@ "caption": "Déficit acumulado de las últimas {nights} noches", "clearValue": "Al día", "clearCaption": "Sin déficit acumulado en las últimas {nights} noches", - "learning": "Aún estamos midiendo tu sueño — {counted} noches hasta ahora, faltan {remaining} para un balance fiable." + "learning": "Aún estamos midiendo tu sueño — {counted} noches hasta ahora, faltan {remaining} para un balance fiable.", + "computedInfo": "Esta es la estimación propia de HealthLog: un balance móvil de la necesidad menos el sueño de las últimas noches, que el sueño de recuperación va reduciendo. Puede diferir de la cifra de deuda de sueño nativa de un wearable, que usa su propio modelo de necesidad." }, "average": { "title": "Media por noche", @@ -3585,28 +3617,7 @@ }, "sleepQuality": { "title": "Calidad del sueño", - "subtitle": "Puntuaciones nocturnas de tu wearable", - "assessment": { - "metric": { - "SLEEP_SCORE": "tu puntuación global de sueño", - "SLEEP_EFFICIENCY": "la eficiencia del sueño", - "SLEEP_PERFORMANCE": "el rendimiento del sueño", - "SLEEP_CONSISTENCY": "la regularidad del sueño" - }, - "grade": { - "excellent": "excelente", - "good": "sólida", - "fair": "aceptable", - "low": "más bien baja" - }, - "clause": "En las últimas 30 noches {metric} es {grade}, en {value}.", - "closing": { - "excellent": "Mantén la rutina que te trae hasta aquí.", - "good": "Acostarte y levantarte a horas más estables suele ser la siguiente mejora.", - "fair": "Proteger una franja de sueño constante es lo que más suele subir esto.", - "low": "Una rutina de relajación regular y una hora fija para despertar son las primeras palancas." - } - } + "subtitle": "Puntuaciones nocturnas de tu wearable" }, "recovery": { "title": "Recuperación", @@ -3666,7 +3677,86 @@ }, "updatedTodayAt": "Actualizado hoy, {time}", "updatedYesterday": "Actualizado ayer", - "updatedOn": "Actualizado el {date}" + "updatedOn": "Actualizado el {date}", + "healthStatus": { + "sectionTitle": "Qué ha cambiado", + "subtitle": "Señales que se desvían de tu rango habitual.", + "deviationAbove": "{vital} está por encima de tu rango habitual.", + "deviationBelow": "{vital} está por debajo de tu rango habitual.", + "shiftUp": "{vital} ha subido respecto a tu normal reciente.", + "shiftDown": "{vital} ha bajado respecto a tu normal reciente.", + "rangeContext": "Ahora {value}, normalmente {low}–{high}.", + "shiftContext": "Cerca de {before} antes, {after} desde entonces." + }, + "breathingScreening": { + "sectionTitle": "Respiración durante el sueño", + "subtitle": "Una señal de cribado de tus datos de sueño.", + "classificationElevated": "Tu dispositivo detectó alteraciones respiratorias elevadas.", + "classificationNotElevated": "La clasificación de tu dispositivo no es elevada.", + "trendUp": "Con tendencia al alza respecto a noches anteriores.", + "trendDown": "Con tendencia a la baja respecto a noches anteriores.", + "trendStable": "Estable respecto a noches anteriores.", + "nights": "{count} noches registradas", + "indexLine": "índice medio reciente {value}", + "eventsFlagged": "{count} eventos detectados por tu dispositivo", + "disclaimer": "Es una señal de cribado, no un diagnóstico: consultalo con un profesional médico." + }, + "labsChanges": { + "sectionTitle": "Desde tu último análisis", + "subtitle": "Cómo se comparan tus últimos resultados con el análisis anterior.", + "statusInRange": "dentro del rango", + "statusBelow": "por debajo del rango", + "statusAbove": "por encima del rango", + "directionFlat": "sin cambios" + }, + "navGripStrength": "Fuerza de agarre", + "navPainNrs": "Dolor", + "navWaistCircumference": "Cintura", + "navWaistToHeight": "Cintura/altura", + "gripStrength": { + "title": "Fuerza de agarre", + "description": "Fuerza máxima de agarre de la mano: un indicador robusto de la fuerza de todo el cuerpo. Por debajo del umbral según el sexo (hombres 27 kg, mujeres 16 kg) indica probable sarcopenia; tu propia tendencia guía la lectura.", + "chartTitle": "Fuerza de agarre", + "emptyState": { + "title": "Aún no hay fuerza de agarre", + "description": "Registra una lectura con un dinamómetro de mano (anota el mejor de varios apretones) y la tendencia aparecerá aquí.", + "cta": "Registrar fuerza de agarre" + }, + "coachPrefill": "Aún no he registrado la fuerza de agarre: ¿qué dice sobre mi salud muscular y cómo la mido y la mejoro?" + }, + "pain": { + "title": "Dolor (0–10)", + "description": "Intensidad del dolor referida por el paciente en la escala numérica 0–10, donde 0 es sin dolor y 10 el peor imaginable. Un cambio sostenido de 2 puntos es la señal a vigilar.", + "chartTitle": "Dolor (0–10)", + "emptyState": { + "title": "Aún no hay puntuaciones de dolor", + "description": "Registra cómo sientes el dolor ahora mismo en la escala 0–10 y la tendencia aparecerá aquí.", + "cta": "Registrar una puntuación de dolor" + }, + "coachPrefill": "Aún no he registrado puntuaciones de dolor: ¿cómo debo seguir el dolor en la escala 0–10 y qué cuenta como un cambio significativo?" + }, + "waist": { + "title": "Perímetro de cintura", + "description": "Perímetro de la cintura en centímetros: un marcador de grasa central. El umbral de riesgo aumentado de la OMS depende del sexo (hombres 94 cm, mujeres 80 cm); tu propia tendencia guía la lectura.", + "chartTitle": "Perímetro de cintura", + "emptyState": { + "title": "Aún no hay medidas de cintura", + "description": "Mide tu cintura en el punto medio entre la costilla más baja y el hueso de la cadera, al final de una espiración normal, y regístrala aquí.", + "cta": "Registrar perímetro de cintura" + }, + "coachPrefill": "Aún no he registrado medidas de cintura: ¿cómo mido la cintura correctamente y qué significa el número para mi salud?" + }, + "waistToHeight": { + "title": "Índice cintura-altura", + "description": "Tu cintura dividida por tu altura: el número único más robusto entre poblaciones. Mantenerlo por debajo de 0,5 (cintura menor que la mitad de tu altura) es el objetivo sencillo.", + "chartTitle": "Índice cintura-altura", + "emptyState": { + "title": "Aún no hay índice cintura-altura", + "description": "Registra un índice cintura-altura y la tendencia aparecerá aquí. Regla práctica: mantén tu cintura por debajo de la mitad de tu altura.", + "cta": "Registrar índice cintura-altura" + }, + "coachPrefill": "Aún no he registrado un índice cintura-altura: ¿por qué es un número útil y cómo lo mantengo por debajo de 0,5?" + } }, "thresholds": { "defaultLabel": "Por defecto", @@ -4012,7 +4102,8 @@ "skinTemperature": "Temperatura de la piel", "respiratoryRate": "Frecuencia respiratoria", "recovery": "Recuperación", - "stress": "Estrés" + "stress": "Estrés", + "sleepDebt": "Deuda de sueño" }, "sourceLabels": { "WITHINGS": "Withings", @@ -4235,6 +4326,50 @@ "mcp": { "title": "Conector MCP", "subtitle": "Conecta un asistente externo a tus propios datos de salud." + }, + "anamnesis": { + "title": "Historial médico", + "subtitle": "Gestiona tus alergias y tus antecedentes familiares.", + "description": "Registros estructurados de alergias y antecedentes familiares." + }, + "environment": { + "title": "Entorno", + "subtitle": "Contexto de tiempo, luz diurna y ubicación.", + "disabledHint": "Activa el módulo Entorno para definir una ubicación principal e incorporar el tiempo y las horas de luz diarias.", + "searchPlaceholder": "Buscar una ciudad", + "search": "Buscar", + "searchError": "No se pudo buscar ese lugar.", + "use": "Usar", + "select": "Seleccionar", + "saveError": "No se pudo guardar el cambio.", + "homeSaved": "Ubicación principal guardada.", + "travelAdded": "Periodo de ubicación añadido.", + "travelRemoved": "Periodo de ubicación eliminado.", + "backfillQueued": "Relleno programado.", + "backfillError": "No se pudo iniciar el relleno.", + "startDate": "Fecha de inicio", + "endDate": "Fecha de fin", + "storedDays": "{count} días de tiempo almacenados.", + "home": { + "title": "Ubicación principal", + "description": "Una ubicación aproximada a nivel de ciudad, nunca un rastro preciso. El tiempo y la luz diurna se obtienen para este lugar en los días sin un periodo de ubicación, desde la fecha en que lo configuras.", + "current": "Actual: {label}", + "none": "Aún no hay ubicación principal.", + "since": "Tiempo registrado desde {date}. Los días anteriores quedan vacíos hasta que añadas un periodo de ubicación." + }, + "travel": { + "title": "Periodos de ubicación", + "description": "Añade un lugar y un rango de fechas: un viaje o un tramo de tu historial anterior. El periodo anula tu hogar en los días que cubre, para que el tiempo coincida con donde estuviste realmente. Así corriges el pasado.", + "none": "Aún no hay periodos de ubicación.", + "add": "Añadir {label}", + "remove": "Eliminar periodo de ubicación" + }, + "backfill": { + "title": "Rellenar días pasados", + "description": "Obtén y guarda el tiempo de un rango de fechas pasado para que los días antiguos entren en las correlaciones. El inicio toma por defecto la fecha de vigencia de tu hogar; para llegar más atrás, añade primero un periodo de ubicación para ese rango.", + "run": "Rellenar" + }, + "noResults": "No se encontraron lugares coincidentes." } }, "profile": "Perfil", @@ -6076,7 +6211,13 @@ "statusOff": "Desactivado", "enabledToast": "Avisos proactivos del Coach activados.", "disabledToast": "Avisos proactivos del Coach desactivados.", - "saveError": "No se pudo guardar. Inténtalo de nuevo más tarde." + "saveError": "No se pudo guardar. Inténtalo de nuevo más tarde.", + "suggestionsLabel": "Sugerencias de ejemplo", + "suggestionsDesc": "Muestra de vez en cuando una pregunta de ejemplo en la pantalla del Coach.", + "suggestionsAria": "Activar o desactivar las sugerencias de ejemplo del Coach", + "aiComposedLabel": "Deja que el Coach redacte el aviso", + "aiComposedDesc": "Cuando hay un proveedor disponible, el aviso proactivo lo redacta el modelo en lugar de la plantilla fija. Desactivado por defecto.", + "aiComposedAria": "Activar o desactivar el aviso proactivo redactado por IA" }, "lowStock": { "title": "Aviso de existencias bajas", @@ -6173,7 +6314,9 @@ "resetToDefault": "Restablecer por defecto", "saveSuccess": "Rango objetivo actualizado", "saveError": "No se ha podido guardar el rango objetivo", - "resetSuccess": "Rango objetivo restablecido por defecto" + "resetSuccess": "Rango objetivo restablecido por defecto", + "validationOrder": "El mínimo debe ser inferior al máximo.", + "validationBounds": "Introduce valores dentro del rango permitido que se muestra debajo." }, "currentValue": "Actual: {value} {unit}", "targetRangeValue": "Rango objetivo: {min}–{max} {unit}", @@ -6305,21 +6448,18 @@ "dailyBody": "¿Cómo te sientes hoy?" }, "coachNudges": { - "complianceTitle": "Tu Coach tiene algo que comentarte", - "complianceBody": "Tus tomas han sido irregulares esta semana. Habla un momento con el Coach: quizá se pueda simplificar el plan.", - "bpTitle": "Tensión elevada esta semana", - "bpBody": "Tu tensión media de esta semana superó tu objetivo. El Coach puede ayudarte a poner los valores en contexto.", - "scoreTitle": "Tu puntuación de recuperación baja", - "scoreBody": "Tu puntuación de recuperación ha caído notablemente esta semana. El Coach puede ayudarte a acotar las posibles causas.", - "selfContextTitle": "Ayuda a tu Coach a conocerte mejor", - "selfContextBody": "Tu autodescripción está incompleta o lleva tiempo sin actualizarse. Dos minutos en los ajustes mantienen fresco el contexto del Coach.", - "weightTitle": "Tu peso se aleja de tu rango objetivo", - "weightBody": "Tu media semanal se ha alejado más de tu rango objetivo. El Coach puede ayudarte a hacer balance con calma.", - "sleepDebtTitle": "Varias noches cortas esta semana", - "sleepDebtBody": "La mayoría de tus últimas noches quedaron por debajo de tu objetivo de sueño. El Coach puede ayudarte a buscar patrones.", - "measurementGapTitle": "Tu registro se ha quedado en silencio", - "measurementGapBody": "Hace una semana que no llegan valores nuevos. Una breve charla con el Coach ayuda a retomar la rutina — o a detectar una sincronización rota.", - "focusSuffix": "Querías prestar atención a: {focus}.", + "greetingMorning": "Buenos días", + "greetingMorningNamed": "Buenos días, {name}", + "greetingHey": "Hola", + "greetingHeyNamed": "Hola, {name}", + "complianceBody": "Esta semana tu rutina de tomas se descuadró un poco. Si quieres, vemos juntos qué te facilitaría mantenerla.", + "bpBody": "Tu tensión estuvo esta semana un poco por encima de tu rango habitual. Cuando te venga bien, la miro contigo encantado.", + "scoreBody": "Tu recuperación ha ido aflojando estos últimos días. Si tienes curiosidad por la causa, aquí estoy para hablarlo.", + "selfContextBody": "Hace tiempo que no actualizas tu autodescripción. Cuando tengas un momento, refrescarla me ayuda a entenderte mejor.", + "weightBody": "Tu peso se ha alejado un poco de tu rango objetivo últimamente. Si quieres, hacemos balance juntos, con calma.", + "sleepDebtBody": "Varias de tus noches se quedaron cortas esta semana. Si te apetece, buscamos un patrón juntos.", + "measurementGapBody": "Tu registro lleva una semana en silencio. Cuando te venga bien, una breve puesta al día ayuda a retomarlo.", + "focusNote": "También tiene que ver con eso que querías vigilar.", "questionConditions": "¿Tienes enfermedades crónicas que el Coach deba conocer?", "questionFocus": "¿Hay algo concreto en lo que el Coach deba fijarse?", "questionAllergies": "¿Tienes alergias o intolerancias?", @@ -6358,6 +6498,11 @@ "label": "Frase de acceso", "error": "Esa frase de acceso no es correcta.", "submit": "Desbloquear" + }, + "loadError": { + "title": "No se pudo cargar esta vista compartida", + "description": "Se produjo un error al abrir este registro de salud compartido. Puede que el enlace haya caducado o que haya habido un problema temporal. Inténtalo de nuevo o pide un enlace nuevo a la persona que lo compartió.", + "retry": "Intentar de nuevo" } }, "cycle": { @@ -6831,7 +6976,8 @@ "empty": "No hay revisiones próximas.", "openLink": "Abrir revisiones" }, - "showMeasurements": "Ver mediciones" + "showMeasurements": "Ver mediciones", + "loadError": "No se pudieron cargar tus recordatorios." }, "moduleList": { "viewToggleLabel": "Vista", @@ -6942,7 +7088,7 @@ "deletedToast": "Biomarcador eliminado", "deleteError": "No se pudo eliminar el biomarcador", "deleteConfirmTitle": "¿Eliminar biomarcador?", - "deleteConfirmDescription": "Se elimina la definición. Las mediciones existentes se conservan pero se desvinculan.", + "deleteConfirmDescription": "El biomarcador y todos sus valores registrados se eliminan de forma permanente. Esta acción no se puede deshacer.", "emptyTitle": "Aún no hay biomarcadores", "emptyDescription": "Define marcadores como LDL o HbA1c para registrar valores.", "loadError": "No se pudieron cargar los biomarcadores", @@ -6977,7 +7123,14 @@ "unhiddenToast": "Biomarcador restaurado", "hideError": "No se pudo actualizar el biomarcador", "allHidden": "Todos los biomarcadores están ocultos.", - "hiddenSection": "Ocultos" + "hiddenSection": "Ocultos", + "targetRange": { + "title": "Ajustar el rango objetivo", + "description": "Define la referencia mínima y máxima de este marcador. Deja un campo vacío para un límite abierto.", + "save": "Guardar rango", + "savedToast": "Rango objetivo actualizado", + "saveError": "No se pudo actualizar el rango objetivo" + } }, "catalog": { "total-cholesterol": "Colesterol total", @@ -7044,8 +7197,16 @@ "hemoglobin": "La proteína de los glóbulos rojos que transporta el oxígeno.", "hematocrit": "La fracción del volumen sanguíneo formada por glóbulos rojos.", "wbc": "Recuento total de glóbulos blancos, la defensa circulante del sistema inmunitario.", - "platelets": "Fragmentos celulares que se agregan para formar coágulos sanguíneos." - } + "platelets": "Fragmentos celulares que se agregan para formar coágulos sanguíneos.", + "apob": "Cuenta directamente las partículas aterogénicas; a menudo mejor marcador de riesgo que el LDL-C cuando discrepan.", + "lp-a": "Factor de riesgo en gran parte hereditario y de por vida — suele medirse una vez. Informa en unidades molares (nmol/L).", + "fasting-insulin": "Se combina con la glucosa en ayunas para estimar la resistencia a la insulina (HOMA-IR).", + "omega-3-index": "Proporción de EPA+DHA en glóbulos rojos; 8–12 % es el rango cardioprotector citado habitualmente." + }, + "apob": "Apolipoproteína B (ApoB)", + "lp-a": "Lipoproteína(a)", + "fasting-insulin": "Insulina en ayunas", + "omega-3-index": "Índice omega-3" }, "noteLoadFailed": "No se pudo cargar la nota; se mantendrá sin cambios al guardar.", "showingLatestOf": "Mostrando los {shown} resultados más recientes de {total}", @@ -7092,7 +7253,8 @@ "fileType": "Sube un JPEG, PNG, WebP o PDF.", "extractFailed": "No se pudo leer el informe. Prueba con una foto más nítida.", "pdfNeedsAnthropic": "El escaneo de PDF necesita un proveedor de visión de Claude; sube una foto en su lugar." - } + }, + "loadMoreReadings": "Cargar más mediciones" }, "modules": { "cycle": { @@ -7150,6 +7312,18 @@ "mcp": { "label": "Acceso de asistente externo (MCP)", "description": "Permite que un asistente compatible con MCP lea tus datos de salud mediante un token con alcance limitado. Desactivado de forma predeterminada." + }, + "environment": { + "label": "Entorno", + "description": "Incorpora el tiempo y las horas de luz diarias de tu ubicación para correlacionarlos con el ánimo, el sueño y las constantes. Desactivado por defecto." + }, + "inboundDocuments": { + "label": "Documentos entrantes", + "description": "Sube un informe médico o un alta; revisa los datos extraídos antes de guardarlos. Desactivado de forma predeterminada." + }, + "mentalHealth": { + "label": "Bienestar mental", + "description": "Cuestionarios PHQ-9 y GAD-7 opcionales, junto al registro de ánimo. Desactivado por defecto; las respuestas se cifran y se mantienen fuera del Coach de IA." } }, "illness": { @@ -7202,8 +7376,8 @@ "note": "Nota", "notePlaceholder": "Algo que valga la pena recordar (cifrado).", "saveError": "No se pudo guardar el episodio. Inténtalo de nuevo.", - "parent": "Part of", - "parentNone": "Not linked" + "parent": "Parte de", + "parentNone": "Sin vincular" }, "sheet": { "title": "Registrar {date}", @@ -7284,35 +7458,36 @@ "fever": "Temperatura: {value} °C", "empty": "Todavía no hay días registrados." }, - "loadError": "Could not load this episode.", - "backToList": "Back to journal", + "loadError": "No se pudo cargar este episodio.", + "backToList": "Volver al diario", "detail": { "note": "Note" }, "edit": { - "title": "Edit episode", - "description": "Update this episode's details." + "title": "Editar episodio", + "description": "Actualiza los detalles de este episodio." }, "menu": { - "label": "Episode actions" + "label": "Acciones del episodio" }, "deleteConfirm": { - "title": "Delete this episode?", - "body": "“{label}” and all its day-logs will be removed. This cannot be undone." + "title": "¿Eliminar este episodio?", + "body": "«{label}» y todos sus registros diarios se eliminarán. Esta acción no se puede deshacer." }, "vital": { - "RESTING_HEART_RATE": "Resting heart rate", - "HEART_RATE_VARIABILITY": "Heart-rate variability", + "RESTING_HEART_RATE": "Frecuencia cardíaca en reposo", + "HEART_RATE_VARIABILITY": "Variabilidad de la frecuencia cardíaca", "RECOVERY_SCORE": "Recovery", - "OXYGEN_SATURATION": "Oxygen saturation", - "BODY_TEMPERATURE": "Body temperature", - "SKIN_TEMPERATURE": "Skin temperature", - "RESPIRATORY_RATE": "Respiratory rate", - "BLOOD_PRESSURE_SYS": "Systolic blood pressure", - "BLOOD_PRESSURE_DIA": "Diastolic blood pressure", + "OXYGEN_SATURATION": "Saturación de oxígeno", + "BODY_TEMPERATURE": "Temperatura corporal", + "SKIN_TEMPERATURE": "Temperatura de la piel", + "RESPIRATORY_RATE": "Frecuencia respiratoria", + "BLOOD_PRESSURE_SYS": "Presión arterial sistólica", + "BLOOD_PRESSURE_DIA": "Presión arterial diastólica", "PULSE": "Pulse", "WEIGHT": "Weight" - } + }, + "listLoadError": "No se pudieron cargar tus episodios." }, "coach": { "reminderSuggestion": { @@ -7372,5 +7547,318 @@ "glucoseHighDoctor": "Dos lecturas recientes fueron de {value} mg/dL o más. Vuelve a medir, hidrátate y contacta a tu médico cuanto antes si sigue así de alta.", "glucoseHighEmergency": "Dos lecturas recientes fueron de {value} mg/dL o más e indicaste síntomas. Si tienes vómitos, respiración rápida o confusión, llama a emergencias ya." } + }, + "version": { + "updateAvailable": { + "title": "Hay una nueva versión disponible", + "reload": "Recargar", + "dismiss": "Descartar" + } + }, + "mentalHealth": { + "pageTitle": "Revisión de bienestar mental", + "pageDescription": "Dos autoevaluaciones validadas y opcionales — PHQ-9 (estado de ánimo) y GAD-7 (preocupación) — junto a tu registro de ánimo. Lo valioso es la tendencia en el tiempo, no un único número.", + "disclaimer": "Esto es una autoevaluación de cribado, no un diagnóstico. Una puntuación alta o baja no significa que tengas o no una afección. Solo un profesional de salud cualificado puede diagnosticar. Si tienes dudas, habla con tu médico.", + "optInNote": "Opcional y privado: tus respuestas individuales están cifradas, se mantienen fuera del Coach de IA y de las exportaciones, y nunca se comparten.", + "attributionLabel": "Fuente", + "choosePrompt": "Elige una revisión", + "start": "Empezar", + "back": "Atrás", + "stem": "Durante las últimas 2 semanas, ¿con qué frecuencia te han molestado los siguientes problemas?", + "submit": "Ver mi resultado", + "submitting": "Guardando…", + "saved": "Guardado", + "error": "Algo salió mal. Inténtalo de nuevo.", + "considerProfessional": "Tu puntuación está en un rango en el que hablar con un profesional de salud puede ayudar. Es una sugerencia amable, no un diagnóstico.", + "instrument": { + "phq9": "PHQ-9 — ánimo", + "gad7": "GAD-7 — preocupación" + }, + "instrumentSub": { + "phq9": "Cribado de depresión, 9 preguntas", + "gad7": "Cribado de ansiedad, 7 preguntas" + }, + "options": { + "0": "Nunca", + "1": "Varios días", + "2": "Más de la mitad de los días", + "3": "Casi todos los días" + }, + "functionalTitle": "Si marcaste algún problema, ¿cuánta dificultad te han causado para trabajar, ocuparte de la casa o relacionarte con otras personas?", + "functional": { + "0": "Ninguna dificultad", + "1": "Algo de dificultad", + "2": "Mucha dificultad", + "3": "Dificultad extrema" + }, + "result": { + "title": "Tu resultado", + "totalLabel": "Puntuación total", + "bandLabel": "Rango para este cribado", + "takeAnother": "Hacer otra revisión" + }, + "band": { + "PHQ9": { + "minimal": "Ninguna / mínima", + "mild": "Leve", + "moderate": "Moderada", + "modSevere": "Moderadamente grave", + "severe": "Grave" + }, + "GAD7": { + "minimal": "Ninguna / mínima", + "mild": "Leve", + "moderate": "Moderada", + "severe": "Grave" + } + }, + "history": { + "title": "Tu historial", + "empty": "Aún no hay revisiones. Tu tendencia aparecerá aquí cuando completes una." + }, + "crisis": { + "title": "No tienes que afrontar esto a solas", + "intro": "Indicaste pensamientos de estar mejor muerto o de hacerte daño. Hay apoyo disponible ahora mismo, y pedir ayuda es una señal de fortaleza.", + "ifDanger": "Si estás en peligro inmediato, llama ahora al {emergency}.", + "resourcesTitle": "Personas que pueden ayudar" + }, + "items": { + "phq9": { + "1": "Tener poco interés o placer en hacer las cosas", + "2": "Sentirse desanimado/a, deprimido/a o sin esperanza", + "3": "Tener problemas para dormir o mantenerse dormido/a, o dormir demasiado", + "4": "Sentirse cansado/a o tener poca energía", + "5": "Tener poco apetito o comer en exceso", + "6": "Sentirse mal con usted mismo/a, o sentir que es un fracaso o que se ha fallado a sí mismo/a o a su familia", + "7": "Tener dificultad para concentrarse en cosas tales como leer el periódico o ver la televisión", + "8": "¿Moverse o hablar tan despacio que otras personas podrían haberlo notado? O lo contrario: estar tan inquieto/a o agitado/a que se ha estado moviendo mucho más de lo habitual", + "9": "Pensar que estaría mejor muerto/a o en hacerse daño de alguna manera" + }, + "gad7": { + "1": "Sentirse nervioso/a, ansioso/a o muy alterado/a", + "2": "No poder dejar de preocuparse o no poder controlar la preocupación", + "3": "Preocuparse demasiado por diferentes cosas", + "4": "Tener dificultad para relajarse", + "5": "Estar tan inquieto/a que es difícil quedarse sentado/a tranquilamente", + "6": "Molestarse o irritarse fácilmente", + "7": "Sentir miedo como si algo terrible fuera a suceder" + } + }, + "crisisResource": { + "telefonSeelsorge": { + "name": "TelefonSeelsorge (24/7, gratuito y confidencial)" + }, + "nummerGegenKummer": { + "name": "Nummer gegen Kummer (para jóvenes)" + }, + "krisenchat": { + "name": "krisenchat (chat 24/7, para jóvenes)" + }, + "findahelpline": { + "name": "Find a Helpline (directorio mundial)" + }, + "euEmotionalSupport": { + "name": "Línea de apoyo emocional" + }, + "lifeline988": { + "name": "988 Suicide & Crisis Lifeline" + }, + "crisisTextLine": { + "name": "Crisis Text Line" + } + } + }, + "records": { + "disclaimer": "Son tus propios registros, no un diagnóstico médico. HealthLog no diagnostica, no trata ni los interpreta.", + "allergies": { + "cardTitle": "Alergias e intolerancias", + "cardDescription": "Sustancias a las que reaccionas, con la reacción y su gravedad.", + "description": "Registrar una alergia o intolerancia.", + "add": "Añadir alergia", + "addFirst": "Añade tu primera alergia", + "addTitle": "Añadir alergia", + "editTitle": "Editar alergia", + "formDescription": "La sustancia se guarda como texto; la reacción y la nota se cifran.", + "emptyTitle": "No hay alergias registradas", + "emptyDescription": "Añade sustancias a las que reaccionas y sus reacciones.", + "loadError": "No se han podido cargar tus alergias.", + "deletedToast": "Alergia eliminada", + "deleteError": "No se ha podido eliminar la alergia.", + "deleteConfirmTitle": "¿Eliminar esta alergia?", + "deleteConfirmDescription": "Esto elimina el registro. No se puede deshacer.", + "category": { + "FOOD": "Alimento", + "MEDICATION": "Medicamento", + "ENVIRONMENT": "Ambiental", + "BIOLOGIC": "Biológico", + "OTHER": "Otro" + }, + "type": { + "ALLERGY": "Alergia", + "INTOLERANCE": "Intolerancia" + }, + "severity": { + "NONE": "Sin evaluar", + "MILD": "Leve", + "MODERATE": "Moderada", + "SEVERE": "Grave" + }, + "status": { + "ACTIVE": "Activa", + "INACTIVE": "Inactiva", + "RESOLVED": "Resuelta" + }, + "form": { + "substance": "Sustancia", + "substancePlaceholder": "p. ej. Penicilina, Cacahuetes", + "category": "Categoría", + "type": "Tipo", + "severity": "Gravedad", + "status": "Estado", + "onset": "Primera vez observada", + "reaction": "Reacción", + "reactionPlaceholder": "p. ej. urticaria, hinchazón, anafilaxia", + "note": "Nota", + "notePlaceholder": "Cualquier otra cosa que merezca recordar", + "requiredError": "Introduce la sustancia.", + "savedToast": "Alergia guardada", + "updatedToast": "Alergia actualizada", + "saveError": "No se ha podido guardar la alergia." + } + }, + "family": { + "cardTitle": "Antecedentes familiares", + "cardDescription": "Enfermedades de tus familiares, una entrada por enfermedad.", + "description": "Registrar una enfermedad de un familiar.", + "add": "Añadir entrada", + "addFirst": "Añade tu primera entrada", + "addTitle": "Añadir antecedente familiar", + "editTitle": "Editar antecedente familiar", + "formDescription": "El parentesco y la enfermedad se guardan como texto; la nota se cifra.", + "emptyTitle": "No hay antecedentes familiares", + "emptyDescription": "Añade enfermedades que aparecen en tu familia.", + "loadError": "No se han podido cargar tus antecedentes familiares.", + "deletedToast": "Entrada eliminada", + "deleteError": "No se ha podido eliminar la entrada.", + "deleteConfirmTitle": "¿Eliminar esta entrada?", + "deleteConfirmDescription": "Esto elimina el registro. No se puede deshacer.", + "ageAtOnsetShort": "Edad {age}", + "relationship": { + "MOTHER": "Madre", + "FATHER": "Padre", + "SISTER": "Hermana", + "BROTHER": "Hermano", + "DAUGHTER": "Hija", + "SON": "Hijo", + "GRANDMOTHER_MATERNAL": "Abuela (materna)", + "GRANDFATHER_MATERNAL": "Abuelo (materno)", + "GRANDMOTHER_PATERNAL": "Abuela (paterna)", + "GRANDFATHER_PATERNAL": "Abuelo (paterno)", + "AUNT": "Tía", + "UNCLE": "Tío", + "COUSIN": "Primo/a", + "HALF_SIBLING": "Medio hermano/a", + "OTHER": "Otro familiar" + }, + "form": { + "relationship": "Familiar", + "condition": "Enfermedad", + "conditionPlaceholder": "p. ej. Diabetes tipo 2, Cáncer de mama", + "ageAtOnset": "Edad de inicio", + "ageAtOnsetPlaceholder": "p. ej. 55", + "note": "Nota", + "notePlaceholder": "Cualquier otra cosa que merezca recordar", + "requiredError": "Introduce la enfermedad.", + "ageError": "Introduce una edad entre 0 y 120.", + "savedToast": "Entrada guardada", + "updatedToast": "Entrada actualizada", + "saveError": "No se ha podido guardar la entrada." + } + } + }, + "environment": { + "fields": { + "tempMean": "Temperatura diaria", + "tempMin": "Mínima nocturna", + "sunshine": "Sol", + "daylight": "Luz diurna", + "precip": "Precipitación", + "pressureMean": "Presión atmosférica", + "pressureDelta": "Variación de presión" + } + }, + "documents": { + "title": "Documentos entrantes", + "subtitle": "Sube un documento clínico y revisa los datos que hemos leído.", + "disclaimer": { + "title": "Leemos, no interpretamos", + "body": "Solo transcribimos lo que indica tu documento — valores, códigos y fechas — y nunca diagnosticamos, interpretamos ni marcamos nada. No se guarda nada hasta que lo revises y lo apruebes. Solo tu profesional sanitario puede interpretar estos resultados." + }, + "upload": { + "label": "Subir un documento", + "kindLabel": "Tipo de documento", + "button": "Subir", + "uploading": "Leyendo…", + "fileLabel": "Archivo del documento" + }, + "kind": { + "DOCTOR_REPORT": "Informe médico", + "DISCHARGE_LETTER": "Alta hospitalaria", + "OTHER": "Otro" + }, + "list": { + "empty": "Aún no hay documentos." + }, + "status": { + "EXTRACTING": "Leyendo", + "EXTRACTED": "Listo para revisar", + "FAILED": "No se pudo leer", + "CONFIRMED": "Revisado", + "DISCARDED": "Descartado" + }, + "review": { + "title": "Revisar datos extraídos", + "discard": "Descartar", + "confirm": "Guardar datos aprobados", + "confirming": "Guardando…", + "needsReview": "Requiere revisión", + "approve": "Aprobar", + "reject": "Rechazar", + "edit": "Editar", + "cancel": "Cancelar", + "save": "Guardar" + }, + "empty": { + "noFacts": "Nada más que revisar." + }, + "factType": { + "CONDITION": "Diagnóstico", + "OBSERVATION": "Valor", + "MEDICATION_STATEMENT": "Medicación" + }, + "fields": { + "label": "Nombre", + "value": "Valor", + "unit": "Unidad", + "date": "Fecha", + "dose": "Dosis" + }, + "toast": { + "uploaded": "Documento leído. Revisa los datos a continuación.", + "uploadFailed": "No se pudo leer el documento.", + "confirmed": "Datos aprobados guardados.", + "confirmFailed": "No se pudieron guardar tus decisiones.", + "saved": "Dato actualizado.", + "discarded": "Documento descartado." + } + }, + "selfDescription": { + "intendedPurpose": "HealthLog es una herramienta personal de registro de salud y bienestar. Te ayuda a registrar, visualizar y exportar tus propios datos de salud. No diagnostica, trata, cura ni previene ninguna enfermedad, y no es un producto sanitario.", + "coach": "El Coach trabaja con tus propios datos registrados. No es un médico y no da consejo médico: para un diagnóstico o tratamiento, consulta a un profesional sanitario.", + "insights": "Son patrones descriptivos calculados a partir de tus propios datos registrados, no una evaluación clínica ni un diagnóstico.", + "labs": "Son tus propios registros, no un diagnóstico médico. Los rangos de referencia varían según el laboratorio y la persona: solo tu médico puede interpretarlos por ti.", + "medications": "Esto registra los medicamentos que anotas y tu propia adherencia. No evalúa dosis ni interacciones y no es consejo médico: confírmalo con tu farmacéutico o médico.", + "emergency": "Si esto pudiera ser una emergencia, llama ahora a tu número de emergencias local o a un profesional sanitario. HealthLog no puede ayudar en una emergencia.", + "dataPosture": "HealthLog es autoalojado: tus datos de salud permanecen en tu propia instancia, cifrados en reposo. Las funciones de IA usan una clave de proveedor que tú aportas o un modelo local, así que nada sale de tu instancia más allá del proveedor que configures, y no hay analítica de terceros sobre tus datos de salud." } } diff --git a/messages/fr.json b/messages/fr.json index 69f085b74..ae54eb4e2 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -35,7 +35,8 @@ "showPassword": "Afficher le mot de passe", "hidePassword": "Masquer le mot de passe", "learnMore": "En savoir plus", - "dismiss": "Ignorer" + "dismiss": "Ignorer", + "moreInfo": "Plus d'informations" }, "doctorReport": { "title": "Rapport de santé", @@ -238,7 +239,9 @@ "cycle": "Cycle", "labs": "Analyses", "vorsorge": "Examens", - "illness": "Maladie" + "illness": "Maladie", + "documents": "Documents", + "mentalWellbeing": "Bien-être mental" }, "auth": { "login": "Se connecter", @@ -574,7 +577,15 @@ "bulkDeleteSuccess": "{count} mesures supprimées", "bulkDeleteError": "Erreur lors de la suppression des mesures sélectionnées", "bulkDeleteConfirmTitle": "Supprimer {count} mesures ?", - "bulkDeleteConfirmBody": "Les {count} mesures sélectionnées seront définitivement supprimées." + "bulkDeleteConfirmBody": "Les {count} mesures sélectionnées seront définitivement supprimées.", + "typePhq9Score": "Score PHQ-9", + "typeGad7Score": "Score GAD-7", + "typeGripStrength": "Force de préhension", + "typePainNrs": "Douleur (0–10)", + "typeWaistCircumference": "Tour de taille", + "typeWaistToHeight": "Rapport taille-taille", + "unitScore": "pts", + "unitRatio": "ratio" }, "mood": { "title": "Humeur", @@ -750,7 +761,8 @@ "sectionFactors": "Facteurs", "sectionNoteDetails": "Note et détails", "quickTagsHelp": "Vos tags fréquents — en un geste", - "quickTagsExpand": "Plus" + "quickTagsExpand": "Plus", + "loadError": "Impossible de charger vos entrées d'humeur." }, "medications": { "title": "Médicaments", @@ -1852,7 +1864,20 @@ "sat": "Samedi", "sun": "Dimanche" }, - "rangeBand": "Plage" + "rangeBand": "Plage", + "a11y": { + "summary": "Graphique {metric}. Plage de {min} à {max}, dernier {latest}, tendance {trend}.", + "noData": "Graphique {metric}. Aucune donnée disponible.", + "trendUp": "en hausse", + "trendDown": "en baisse", + "trendFlat": "stable", + "scatterSummary": "Graphique de corrélation, {count} points comparant {xLabel} et {yLabel}.", + "scatterNoData": "Graphique de corrélation de {xLabel} et {yLabel}. Aucune donnée disponible.", + "moodHeatmap": "Carte thermique de l'humeur du {start} au {end}, {days} jours enregistrés.", + "moodHeatmapEmpty": "Carte thermique de l'humeur, aucun jour enregistré pour l'instant.", + "complianceHeatmap": "Carte thermique d'observance des médicaments du {start} au {end}, {days} jours actifs.", + "complianceHeatmapEmpty": "Carte thermique d'observance des médicaments, aucun jour actif pour l'instant." + } }, "chart": { "overlay": { @@ -2037,7 +2062,11 @@ "noProviderTitle": "Aucun fournisseur d'IA connecté", "noProviderDescription": "Le briefing quotidien résume tes mesures en langage clair. HealthLog a besoin d'un fournisseur d'IA pour cela : utilise ta propre clé (OpenAI, Anthropic) ou un modèle local. Tes données restent chez toi.", "noProviderAction": "Ouvrir les réglages IA", - "staleNoProviderHint": "Ce briefing ne peut pas être actualisé — aucun fournisseur d'IA n'est connecté." + "staleNoProviderHint": "Ce briefing ne peut pas être actualisé — aucun fournisseur d'IA n'est connecté.", + "refreshFailedHint": "Impossible d'actualiser le briefing — affichage du précédent.", + "retryAction": "Réessayer", + "failedTitle": "Impossible de générer le briefing", + "failedDescription": "Le fournisseur d'IA a dépassé le délai ou renvoyé une erreur. Réessayez ou augmentez le délai de réponse dans les paramètres d'IA pour un modèle local lent." }, "trendsRow": { "title": "Tendances", @@ -2372,7 +2401,8 @@ "correlationMoodPulse": "Y a-t-il un lien réel entre mon humeur et mon pouls ? Aide-moi à interpréter cette corrélation.", "correlationWeightWeekday": "Mon poids suit-il un schéma hebdomadaire ? Que m'indique cette corrélation selon le jour de la semaine ?", "recommendation": "Parle-moi davantage de cette recommandation : \"{text}\" — pourquoi s'applique-t-elle à moi, et que devrais-je faire ?" - } + }, + "assessmentPrompt": "Explique-moi mon évaluation de {metric} : qu'est-ce que cela signifie et que dois-je faire ?" }, "relativeJustNow": "à l’instant", "relativeMinutesAgoOne": "il y a {count} minute", @@ -2444,7 +2474,8 @@ "emptyState": { "title": "Aucune FC au repos", "description": "La FC au repos arrive dès que l'Apple Watch ou un appareil Withings compatible a porté une journée calme." - } + }, + "coachPrefill": "Je n'ai pas encore enregistré de fréquence cardiaque au repos — pourquoi la valeur quotidienne au repos diffère-t-elle d'un pouls ponctuel, et comment l'améliorer ?" }, "oxygenSaturation": { "title": "Saturation en oxygène", @@ -2670,7 +2701,8 @@ "caption": "Déficit cumulé sur les {nights} dernières nuits", "clearValue": "À jour", "clearCaption": "Aucun déficit cumulé sur les {nights} dernières nuits", - "learning": "Votre sommeil est encore en cours d'analyse — {counted} nuits jusqu'ici, encore {remaining} pour un bilan fiable." + "learning": "Votre sommeil est encore en cours d'analyse — {counted} nuits jusqu'ici, encore {remaining} pour un bilan fiable.", + "computedInfo": "Il s'agit de l'estimation propre à HealthLog : un solde glissant entre le besoin et le sommeil des dernières nuits, que le sommeil de récupération réduit. Elle peut différer de la dette de sommeil native d'un wearable, qui utilise son propre modèle de besoin." }, "average": { "title": "Moyenne par nuit", @@ -3585,28 +3617,7 @@ }, "sleepQuality": { "title": "Qualité du sommeil", - "subtitle": "Scores nocturnes de votre wearable", - "assessment": { - "metric": { - "SLEEP_SCORE": "votre score de sommeil global", - "SLEEP_EFFICIENCY": "l’efficacité du sommeil", - "SLEEP_PERFORMANCE": "la performance de sommeil", - "SLEEP_CONSISTENCY": "la régularité du sommeil" - }, - "grade": { - "excellent": "excellente", - "good": "solide", - "fair": "correcte", - "low": "plutôt faible" - }, - "clause": "Sur les 30 dernières nuits, {metric} est {grade}, à {value}.", - "closing": { - "excellent": "Gardez la routine qui vous mène jusque-là.", - "good": "Des heures de coucher et de lever plus stables sont souvent le prochain gain.", - "fair": "Protéger une fenêtre de sommeil constante est ce qui fait le plus progresser ce point.", - "low": "Un rituel d’endormissement régulier et une heure de réveil fixe sont les premiers leviers." - } - } + "subtitle": "Scores nocturnes de votre wearable" }, "recovery": { "title": "Récupération", @@ -3666,7 +3677,86 @@ }, "updatedTodayAt": "Mis à jour aujourd'hui, {time}", "updatedYesterday": "Mis à jour hier", - "updatedOn": "Mis à jour le {date}" + "updatedOn": "Mis à jour le {date}", + "healthStatus": { + "sectionTitle": "Ce qui a changé", + "subtitle": "Signaux s'écartant de votre plage habituelle.", + "deviationAbove": "{vital} est au-dessus de votre plage habituelle.", + "deviationBelow": "{vital} est en dessous de votre plage habituelle.", + "shiftUp": "{vital} a augmenté par rapport à votre normale récente.", + "shiftDown": "{vital} a diminué par rapport à votre normale récente.", + "rangeContext": "Actuellement {value}, habituellement {low}–{high}.", + "shiftContext": "Environ {before} avant, {after} depuis." + }, + "breathingScreening": { + "sectionTitle": "Respiration pendant le sommeil", + "subtitle": "Un signal de dépistage issu de vos données de sommeil.", + "classificationElevated": "Votre appareil a signalé des perturbations respiratoires élevées.", + "classificationNotElevated": "La classification de votre appareil n'est pas élevée.", + "trendUp": "En hausse par rapport à vos nuits précédentes.", + "trendDown": "En baisse par rapport à vos nuits précédentes.", + "trendStable": "Stable par rapport à vos nuits précédentes.", + "nights": "{count} nuits suivies", + "indexLine": "indice moyen récent {value}", + "eventsFlagged": "{count} événements signalés par votre appareil", + "disclaimer": "Il s'agit d'un signal de dépistage, pas d'un diagnostic — parlez-en à un professionnel de santé." + }, + "labsChanges": { + "sectionTitle": "Depuis votre dernier bilan", + "subtitle": "Comparaison de vos derniers résultats avec le bilan précédent.", + "statusInRange": "dans la plage", + "statusBelow": "sous la plage", + "statusAbove": "au-dessus de la plage", + "directionFlat": "aucun changement" + }, + "navGripStrength": "Force de préhension", + "navPainNrs": "Douleur", + "navWaistCircumference": "Tour de taille", + "navWaistToHeight": "Taille/hauteur", + "gripStrength": { + "title": "Force de préhension", + "description": "Force maximale de préhension de la main : un indicateur robuste de la force corporelle globale. Sous le seuil selon le sexe (hommes 27 kg, femmes 16 kg), elle signale une sarcopénie probable ; votre propre tendance guide la lecture.", + "chartTitle": "Force de préhension", + "emptyState": { + "title": "Pas encore de force de préhension", + "description": "Enregistrez une mesure avec un dynamomètre à main (notez le meilleur de plusieurs serrages) et la tendance apparaîtra ici.", + "cta": "Enregistrer la force de préhension" + }, + "coachPrefill": "Je n'ai pas encore enregistré de force de préhension — que dit-elle sur ma santé musculaire, et comment la mesurer et l'améliorer ?" + }, + "pain": { + "title": "Douleur (0–10)", + "description": "Intensité de la douleur rapportée par le patient sur l'échelle numérique 0–10, où 0 signifie aucune douleur et 10 la pire imaginable. Une variation soutenue de 2 points est le signal à surveiller.", + "chartTitle": "Douleur (0–10)", + "emptyState": { + "title": "Pas encore de scores de douleur", + "description": "Enregistrez votre douleur actuelle sur l'échelle 0–10 et la tendance apparaîtra ici.", + "cta": "Enregistrer un score de douleur" + }, + "coachPrefill": "Je n'ai pas encore enregistré de score de douleur — comment suivre la douleur sur l'échelle 0–10, et qu'est-ce qu'un changement significatif ?" + }, + "waist": { + "title": "Tour de taille", + "description": "Tour de taille en centimètres : un marqueur de la graisse abdominale. Le seuil de risque accru de l'OMS dépend du sexe (hommes 94 cm, femmes 80 cm) ; votre propre tendance guide la lecture.", + "chartTitle": "Tour de taille", + "emptyState": { + "title": "Pas encore de mesures de tour de taille", + "description": "Mesurez votre tour de taille à mi-chemin entre la dernière côte et l'os de la hanche, en fin d'expiration normale, et enregistrez-le ici.", + "cta": "Enregistrer le tour de taille" + }, + "coachPrefill": "Je n'ai pas encore enregistré de tour de taille — comment le mesurer correctement, et que signifie ce chiffre pour ma santé ?" + }, + "waistToHeight": { + "title": "Rapport taille-hauteur", + "description": "Votre tour de taille divisé par votre taille : le nombre unique le plus robuste selon les populations. Le maintenir sous 0,5 (tour de taille inférieur à la moitié de votre taille) est l'objectif simple.", + "chartTitle": "Rapport taille-hauteur", + "emptyState": { + "title": "Pas encore de rapport taille-hauteur", + "description": "Enregistrez un rapport taille-hauteur et la tendance apparaîtra ici. Règle simple : gardez votre tour de taille sous la moitié de votre taille.", + "cta": "Enregistrer le rapport taille-hauteur" + }, + "coachPrefill": "Je n'ai pas encore enregistré de rapport taille-hauteur — pourquoi est-ce un chiffre utile, et comment le maintenir sous 0,5 ?" + } }, "thresholds": { "defaultLabel": "Par défaut", @@ -4012,7 +4102,8 @@ "skinTemperature": "Température cutanée", "respiratoryRate": "Fréquence respiratoire", "recovery": "Récupération", - "stress": "Stress" + "stress": "Stress", + "sleepDebt": "Dette de sommeil" }, "sourceLabels": { "WITHINGS": "Withings", @@ -4235,6 +4326,50 @@ "mcp": { "title": "Connecteur MCP", "subtitle": "Connectez un assistant externe à vos propres données de santé." + }, + "anamnesis": { + "title": "Antécédents médicaux", + "subtitle": "Gérez vos allergies et vos antécédents familiaux.", + "description": "Dossiers structurés des allergies et des antécédents familiaux." + }, + "environment": { + "title": "Environnement", + "subtitle": "Contexte météo, lumière du jour et lieu.", + "disabledHint": "Activez le module Environnement pour définir un lieu de référence et intégrer la météo et la durée du jour quotidiennes.", + "searchPlaceholder": "Rechercher une ville", + "search": "Rechercher", + "searchError": "Impossible de rechercher ce lieu.", + "use": "Utiliser", + "select": "Sélectionner", + "saveError": "Impossible d'enregistrer la modification.", + "homeSaved": "Lieu de référence enregistré.", + "travelAdded": "Période de localisation ajoutée.", + "travelRemoved": "Période de localisation supprimée.", + "backfillQueued": "Récupération planifiée.", + "backfillError": "Impossible de lancer la récupération.", + "startDate": "Date de début", + "endDate": "Date de fin", + "storedDays": "{count} jours de météo enregistrés.", + "home": { + "title": "Lieu de référence", + "description": "Une localisation approximative à l'échelle de la ville, jamais un tracé précis. La météo et la lumière du jour sont récupérées pour ce lieu les jours sans période de localisation, à partir de la date où vous le définissez.", + "current": "Actuel : {label}", + "none": "Aucun lieu de référence défini.", + "since": "Météo enregistrée à partir du {date}. Les jours antérieurs restent vides jusqu'à ce que vous ajoutiez une période de localisation." + }, + "travel": { + "title": "Périodes de localisation", + "description": "Ajoutez un lieu et une plage de dates : un voyage ou une portion de votre historique passé. La période remplace votre domicile pour les jours qu'elle couvre, afin que la météo corresponde à l'endroit où vous étiez réellement. C'est ainsi que vous corrigez le passé.", + "none": "Aucune période de localisation pour l'instant.", + "add": "Ajouter {label}", + "remove": "Supprimer la période de localisation" + }, + "backfill": { + "title": "Récupérer les jours passés", + "description": "Récupérez et enregistrez la météo d'une plage de dates passée pour que les jours anciens rejoignent les corrélations. Le début reprend par défaut la date d'effet de votre domicile ; pour remonter plus loin, ajoutez d'abord une période de localisation pour cette plage.", + "run": "Récupérer" + }, + "noResults": "Aucun lieu correspondant trouvé." } }, "profile": "Profil", @@ -6076,7 +6211,13 @@ "statusOff": "Désactivé", "enabledToast": "Rappels proactifs du Coach activés.", "disabledToast": "Rappels proactifs du Coach désactivés.", - "saveError": "Impossible d'enregistrer. Veuillez réessayer plus tard." + "saveError": "Impossible d'enregistrer. Veuillez réessayer plus tard.", + "suggestionsLabel": "Suggestions d'exemples", + "suggestionsDesc": "Affiche de temps en temps une question d'exemple prête à l'emploi sur l'écran du Coach.", + "suggestionsAria": "Activer ou désactiver les suggestions d'exemples du Coach", + "aiComposedLabel": "Laisser le Coach rédiger le message", + "aiComposedDesc": "Quand un fournisseur est disponible, le message proactif est rédigé par le modèle plutôt que par le modèle de texte fixe. Désactivé par défaut.", + "aiComposedAria": "Activer ou désactiver le message proactif rédigé par l'IA" }, "lowStock": { "title": "Alerte de stock faible", @@ -6173,7 +6314,9 @@ "resetToDefault": "Restaurer la valeur par défaut", "saveSuccess": "Plage cible mise à jour", "saveError": "Impossible d’enregistrer la plage cible", - "resetSuccess": "Plage cible réinitialisée" + "resetSuccess": "Plage cible réinitialisée", + "validationOrder": "Le minimum doit être inférieur au maximum.", + "validationBounds": "Saisissez des valeurs dans la plage autorisée indiquée ci-dessous." }, "currentValue": "Actuel : {value} {unit}", "targetRangeValue": "Plage cible : {min}–{max} {unit}", @@ -6305,21 +6448,18 @@ "dailyBody": "Comment vous sentez-vous aujourd’hui ?" }, "coachNudges": { - "complianceTitle": "Votre Coach a une remarque à ce sujet", - "complianceBody": "Vos prises ont été irrégulières cette semaine. Échangez avec le Coach — le plan peut peut-être être simplifié.", - "bpTitle": "Tension élevée cette semaine", - "bpBody": "Votre tension moyenne de la semaine a dépassé votre objectif. Le Coach peut vous aider à mettre les valeurs en perspective.", - "scoreTitle": "Votre score de récupération baisse", - "scoreBody": "Votre score de récupération a nettement chuté cette semaine. Le Coach peut vous aider à cerner les causes possibles.", - "selfContextTitle": "Aidez votre Coach à mieux vous connaître", - "selfContextBody": "Votre auto-description est incomplète ou n'a pas été mise à jour depuis un moment. Deux minutes dans les réglages gardent le contexte du Coach à jour.", - "weightTitle": "Votre poids s'éloigne de votre zone cible", - "weightBody": "Votre moyenne hebdomadaire s'est encore éloignée de votre zone cible. Le Coach peut vous aider à faire le point sereinement.", - "sleepDebtTitle": "Plusieurs nuits courtes cette semaine", - "sleepDebtBody": "La plupart de vos dernières nuits sont restées sous votre objectif de sommeil. Le Coach peut vous aider à chercher des régularités.", - "measurementGapTitle": "Votre journal est devenu silencieux", - "measurementGapBody": "Aucune nouvelle valeur depuis une semaine. Un bref échange avec le Coach aide à reprendre la routine — ou à repérer une synchronisation en panne.", - "focusSuffix": "Vous vouliez surveiller : {focus}.", + "greetingMorning": "Bonjour", + "greetingMorningNamed": "Bonjour, {name}", + "greetingHey": "Salut", + "greetingHeyNamed": "Salut {name}", + "complianceBody": "Vos prises se sont un peu déréglées cette semaine. Si vous voulez, on regarde ensemble ce qui les rendrait plus faciles à suivre.", + "bpBody": "Votre tension est restée un peu au-dessus de votre plage habituelle cette semaine. Quand vous voulez, je la regarde volontiers avec vous.", + "scoreBody": "Votre récupération s'essouffle depuis quelques jours. Si vous êtes curieux d'en connaître la cause, je suis là pour en parler.", + "selfContextBody": "Votre auto-description n'a pas été mise à jour depuis un moment. Quand vous aurez un instant, la rafraîchir m'aide à mieux vous comprendre.", + "weightBody": "Votre poids s'est un peu éloigné de votre zone cible ces derniers temps. Si vous le souhaitez, on fait le point ensemble, tranquillement.", + "sleepDebtBody": "Plusieurs de vos nuits ont été un peu courtes cette semaine. Si vous voulez, on cherche une régularité ensemble.", + "measurementGapBody": "Votre journal est silencieux depuis environ une semaine. Quand cela vous convient, un petit point aide à reprendre.", + "focusNote": "Cela rejoint aussi ce que vous vouliez garder à l'œil.", "questionConditions": "Avez-vous des maladies chroniques que le Coach devrait connaître ?", "questionFocus": "Y a-t-il quelque chose de précis auquel le Coach devrait veiller ?", "questionAllergies": "Avez-vous des allergies ou des intolérances ?", @@ -6358,6 +6498,11 @@ "label": "Phrase secrète", "error": "Cette phrase secrète est incorrecte.", "submit": "Déverrouiller" + }, + "loadError": { + "title": "Impossible de charger cette vue partagée", + "description": "Une erreur s'est produite lors de l'ouverture de ce dossier de santé partagé. Le lien a peut-être expiré, ou un problème temporaire est survenu. Réessayez ou demandez un nouveau lien à la personne qui l'a partagé.", + "retry": "Réessayer" } }, "cycle": { @@ -6831,7 +6976,8 @@ "empty": "Aucun rappel d’examen à venir.", "openLink": "Ouvrir les examens" }, - "showMeasurements": "Afficher les mesures" + "showMeasurements": "Afficher les mesures", + "loadError": "Impossible de charger vos rappels." }, "moduleList": { "viewToggleLabel": "Affichage", @@ -6942,7 +7088,7 @@ "deletedToast": "Biomarqueur supprimé", "deleteError": "Impossible de supprimer le biomarqueur", "deleteConfirmTitle": "Supprimer le biomarqueur ?", - "deleteConfirmDescription": "La définition est supprimée. Les mesures existantes sont conservées mais dissociées.", + "deleteConfirmDescription": "Le biomarqueur et toutes ses valeurs enregistrées sont définitivement supprimés. Cette action est irréversible.", "emptyTitle": "Aucun biomarqueur pour l'instant", "emptyDescription": "Définissez des marqueurs comme LDL ou HbA1c pour enregistrer des valeurs.", "loadError": "Impossible de charger les biomarqueurs", @@ -6977,7 +7123,14 @@ "unhiddenToast": "Biomarqueur restauré", "hideError": "Impossible de mettre à jour le biomarqueur", "allHidden": "Tous les biomarqueurs sont masqués.", - "hiddenSection": "Masqués" + "hiddenSection": "Masqués", + "targetRange": { + "title": "Ajuster la plage cible", + "description": "Définis la référence basse et haute de ce marqueur. Laisse un champ vide pour une limite ouverte.", + "save": "Enregistrer la plage", + "savedToast": "Plage cible mise à jour", + "saveError": "Impossible de mettre à jour la plage cible" + } }, "catalog": { "total-cholesterol": "Cholestérol total", @@ -7044,8 +7197,16 @@ "hemoglobin": "La protéine des globules rouges qui transporte l'oxygène.", "hematocrit": "La fraction du volume sanguin constituée de globules rouges.", "wbc": "Nombre total de globules blancs, la défense circulante du système immunitaire.", - "platelets": "Fragments cellulaires qui s'agrègent pour former des caillots sanguins." - } + "platelets": "Fragments cellulaires qui s'agrègent pour former des caillots sanguins.", + "apob": "Compte directement les particules athérogènes ; souvent un meilleur marqueur de risque que le LDL-C en cas de discordance.", + "lp-a": "Facteur de risque en grande partie héréditaire et permanent — mesuré une seule fois en général. À exprimer en unités molaires (nmol/L).", + "fasting-insulin": "Se combine à la glycémie à jeun pour estimer la résistance à l'insuline (HOMA-IR).", + "omega-3-index": "Part d'EPA+DHA dans les globules rouges ; 8–12 % est la plage cardioprotectrice habituellement citée." + }, + "apob": "Apolipoprotéine B (ApoB)", + "lp-a": "Lipoprotéine(a)", + "fasting-insulin": "Insuline à jeun", + "omega-3-index": "Indice oméga-3" }, "noteLoadFailed": "La note n’a pas pu être chargée ; elle restera inchangée à l’enregistrement.", "showingLatestOf": "Affichage des {shown} derniers résultats sur {total}", @@ -7092,7 +7253,8 @@ "fileType": "Importez un JPEG, PNG, WebP ou PDF.", "extractFailed": "Impossible de lire le compte rendu. Essayez une photo plus nette.", "pdfNeedsAnthropic": "Le scan PDF nécessite un fournisseur de vision Claude ; importez plutôt une photo." - } + }, + "loadMoreReadings": "Charger plus de mesures" }, "modules": { "cycle": { @@ -7150,6 +7312,18 @@ "mcp": { "label": "Accès assistant externe (MCP)", "description": "Autorise un assistant compatible MCP à lire vos données de santé via un jeton à portée limitée. Désactivé par défaut." + }, + "environment": { + "label": "Environnement", + "description": "Intégrez la météo et la durée du jour quotidiennes de votre lieu pour les corréler à l'humeur, au sommeil et aux constantes. Désactivé par défaut." + }, + "inboundDocuments": { + "label": "Documents entrants", + "description": "Importez un compte rendu médical ou une lettre de sortie ; vérifiez les données extraites avant de les enregistrer. Désactivé par défaut." + }, + "mentalHealth": { + "label": "Bien-être mental", + "description": "Questionnaires PHQ-9 et GAD-7 facultatifs, en complément du suivi de l'humeur. Désactivé par défaut ; les réponses sont chiffrées et tenues à l'écart du Coach IA." } }, "illness": { @@ -7202,8 +7376,8 @@ "note": "Note", "notePlaceholder": "Ce qui mérite d'être noté (chiffré).", "saveError": "Impossible d'enregistrer l'épisode. Veuillez réessayer.", - "parent": "Part of", - "parentNone": "Not linked" + "parent": "Fait partie de", + "parentNone": "Non lié" }, "sheet": { "title": "Enregistrer {date}", @@ -7284,35 +7458,36 @@ "fever": "Température : {value} °C", "empty": "Aucun jour enregistré pour l’instant." }, - "loadError": "Could not load this episode.", - "backToList": "Back to journal", + "loadError": "Impossible de charger cet épisode.", + "backToList": "Retour au journal", "detail": { "note": "Note" }, "edit": { - "title": "Edit episode", - "description": "Update this episode's details." + "title": "Modifier l'épisode", + "description": "Mettez à jour les détails de cet épisode." }, "menu": { - "label": "Episode actions" + "label": "Actions de l'épisode" }, "deleteConfirm": { - "title": "Delete this episode?", - "body": "“{label}” and all its day-logs will be removed. This cannot be undone." + "title": "Supprimer cet épisode ?", + "body": "« {label} » et tous ses relevés quotidiens seront supprimés. Cette action est irréversible." }, "vital": { - "RESTING_HEART_RATE": "Resting heart rate", - "HEART_RATE_VARIABILITY": "Heart-rate variability", + "RESTING_HEART_RATE": "Fréquence cardiaque au repos", + "HEART_RATE_VARIABILITY": "Variabilité de la fréquence cardiaque", "RECOVERY_SCORE": "Recovery", - "OXYGEN_SATURATION": "Oxygen saturation", - "BODY_TEMPERATURE": "Body temperature", - "SKIN_TEMPERATURE": "Skin temperature", - "RESPIRATORY_RATE": "Respiratory rate", - "BLOOD_PRESSURE_SYS": "Systolic blood pressure", - "BLOOD_PRESSURE_DIA": "Diastolic blood pressure", + "OXYGEN_SATURATION": "Saturation en oxygène", + "BODY_TEMPERATURE": "Température corporelle", + "SKIN_TEMPERATURE": "Température cutanée", + "RESPIRATORY_RATE": "Fréquence respiratoire", + "BLOOD_PRESSURE_SYS": "Pression artérielle systolique", + "BLOOD_PRESSURE_DIA": "Pression artérielle diastolique", "PULSE": "Pulse", "WEIGHT": "Weight" - } + }, + "listLoadError": "Impossible de charger vos épisodes." }, "coach": { "reminderSuggestion": { @@ -7372,5 +7547,318 @@ "glucoseHighDoctor": "Deux mesures récentes étaient à {value} mg/dL ou plus. Remesure, hydrate-toi et contacte ton médecin dès que possible si cela reste aussi élevé.", "glucoseHighEmergency": "Deux mesures récentes étaient à {value} mg/dL ou plus et tu as signalé des symptômes. En cas de vomissements, de respiration rapide ou de confusion, appelle les secours immédiatement." } + }, + "version": { + "updateAvailable": { + "title": "Une nouvelle version est disponible", + "reload": "Recharger", + "dismiss": "Ignorer" + } + }, + "mentalHealth": { + "pageTitle": "Point sur le bien-être mental", + "pageDescription": "Deux auto-évaluations validées et facultatives — PHQ-9 (humeur) et GAD-7 (inquiétude) — en complément de ton suivi de l'humeur. Ce qui compte, c'est la tendance dans le temps, pas un chiffre isolé.", + "disclaimer": "Il s'agit d'une auto-évaluation de dépistage, pas d'un diagnostic. Un score élevé ou bas ne signifie pas que tu as ou n'as pas un trouble. Seul un professionnel de santé qualifié peut poser un diagnostic. En cas de doute, parles-en à ton médecin.", + "optInNote": "Facultatif et privé : tes réponses individuelles sont chiffrées, tenues à l'écart du Coach IA et des exports, et ne sont jamais partagées.", + "attributionLabel": "Source", + "choosePrompt": "Choisis un bilan", + "start": "Commencer", + "back": "Retour", + "stem": "Au cours des 2 dernières semaines, à quelle fréquence as-tu été gêné(e) par les problèmes suivants ?", + "submit": "Voir mon résultat", + "submitting": "Enregistrement…", + "saved": "Enregistré", + "error": "Une erreur est survenue. Réessaie.", + "considerProfessional": "Ton score se situe dans une plage où parler à un professionnel de santé peut aider. C'est une suggestion bienveillante, pas un diagnostic.", + "instrument": { + "phq9": "PHQ-9 — humeur", + "gad7": "GAD-7 — inquiétude" + }, + "instrumentSub": { + "phq9": "Dépistage de la dépression, 9 questions", + "gad7": "Dépistage de l'anxiété, 7 questions" + }, + "options": { + "0": "Jamais", + "1": "Plusieurs jours", + "2": "Plus de la moitié des jours", + "3": "Presque tous les jours" + }, + "functionalTitle": "Si tu as coché des problèmes, à quel point t'ont-ils gêné(e) dans ton travail, à la maison ou dans tes relations avec les autres ?", + "functional": { + "0": "Pas du tout gênant", + "1": "Un peu gênant", + "2": "Très gênant", + "3": "Extrêmement gênant" + }, + "result": { + "title": "Ton résultat", + "totalLabel": "Score total", + "bandLabel": "Plage pour ce dépistage", + "takeAnother": "Faire un autre bilan" + }, + "band": { + "PHQ9": { + "minimal": "Aucune / minimale", + "mild": "Légère", + "moderate": "Modérée", + "modSevere": "Modérément sévère", + "severe": "Sévère" + }, + "GAD7": { + "minimal": "Aucune / minimale", + "mild": "Légère", + "moderate": "Modérée", + "severe": "Sévère" + } + }, + "history": { + "title": "Ton historique", + "empty": "Aucun bilan pour le moment. Ta tendance apparaîtra ici dès que tu en auras terminé un." + }, + "crisis": { + "title": "Tu n'as pas à affronter cela seul(e)", + "intro": "Tu as indiqué des pensées qu'il vaudrait mieux être mort(e) ou de te faire du mal. De l'aide est disponible dès maintenant, et demander de l'aide est un signe de force.", + "ifDanger": "Si tu es en danger immédiat, appelle le {emergency} maintenant.", + "resourcesTitle": "Des personnes qui peuvent aider" + }, + "items": { + "phq9": { + "1": "Peu d'intérêt ou de plaisir à faire les choses", + "2": "Se sentir triste, déprimé(e) ou désespéré(e)", + "3": "Difficultés à s'endormir ou à rester endormi(e), ou dormir trop", + "4": "Se sentir fatigué(e) ou avoir peu d'énergie", + "5": "Avoir peu d'appétit ou manger trop", + "6": "Avoir une mauvaise opinion de soi-même — ou avoir le sentiment d'être nul(le) ou d'avoir déçu sa famille ou soi-même", + "7": "Avoir du mal à se concentrer, par exemple pour lire le journal ou regarder la télévision", + "8": "Bouger ou parler si lentement que les autres ont pu le remarquer. Ou au contraire, être si agité(e) que vous avez bougé beaucoup plus que d'habitude", + "9": "Penser qu'il vaudrait mieux mourir ou envisager de vous faire du mal d'une manière ou d'une autre" + }, + "gad7": { + "1": "Se sentir nerveux/nerveuse, anxieux/anxieuse ou tendu(e)", + "2": "Être incapable d'arrêter de vous inquiéter ou de contrôler vos inquiétudes", + "3": "Vous inquiéter trop à propos de choses diverses", + "4": "Avoir du mal à vous détendre", + "5": "Être si agité(e) qu'il est difficile de rester tranquille", + "6": "Devenir facilement contrarié(e) ou irritable", + "7": "Avoir peur que quelque chose d'épouvantable puisse arriver" + } + }, + "crisisResource": { + "telefonSeelsorge": { + "name": "TelefonSeelsorge (24h/24, 7j/7, gratuit et confidentiel)" + }, + "nummerGegenKummer": { + "name": "Nummer gegen Kummer (pour les jeunes)" + }, + "krisenchat": { + "name": "krisenchat (chat 24h/24, 7j/7, pour les jeunes)" + }, + "findahelpline": { + "name": "Find a Helpline (annuaire mondial)" + }, + "euEmotionalSupport": { + "name": "Ligne d'écoute et de soutien émotionnel" + }, + "lifeline988": { + "name": "988 Suicide & Crisis Lifeline" + }, + "crisisTextLine": { + "name": "Crisis Text Line" + } + } + }, + "records": { + "disclaimer": "Ce sont vos propres informations, pas un diagnostic médical. HealthLog ne diagnostique pas, ne traite pas et ne les interprète pas.", + "allergies": { + "cardTitle": "Allergies & intolérances", + "cardDescription": "Substances auxquelles vous réagissez, avec la réaction et sa gravité.", + "description": "Enregistrer une allergie ou une intolérance.", + "add": "Ajouter une allergie", + "addFirst": "Ajoutez votre première allergie", + "addTitle": "Ajouter une allergie", + "editTitle": "Modifier l'allergie", + "formDescription": "La substance est stockée en clair ; la réaction et la note sont chiffrées.", + "emptyTitle": "Aucune allergie enregistrée", + "emptyDescription": "Ajoutez les substances auxquelles vous réagissez et leurs réactions.", + "loadError": "Impossible de charger vos allergies.", + "deletedToast": "Allergie supprimée", + "deleteError": "Impossible de supprimer l'allergie.", + "deleteConfirmTitle": "Supprimer cette allergie ?", + "deleteConfirmDescription": "Cela supprime l'enregistrement. C'est irréversible.", + "category": { + "FOOD": "Aliment", + "MEDICATION": "Médicament", + "ENVIRONMENT": "Environnement", + "BIOLOGIC": "Biologique", + "OTHER": "Autre" + }, + "type": { + "ALLERGY": "Allergie", + "INTOLERANCE": "Intolérance" + }, + "severity": { + "NONE": "Non évaluée", + "MILD": "Légère", + "MODERATE": "Modérée", + "SEVERE": "Sévère" + }, + "status": { + "ACTIVE": "Active", + "INACTIVE": "Inactive", + "RESOLVED": "Résolue" + }, + "form": { + "substance": "Substance", + "substancePlaceholder": "p. ex. Pénicilline, Arachides", + "category": "Catégorie", + "type": "Type", + "severity": "Gravité", + "status": "Statut", + "onset": "Première fois remarquée", + "reaction": "Réaction", + "reactionPlaceholder": "p. ex. urticaire, gonflement, anaphylaxie", + "note": "Note", + "notePlaceholder": "Tout autre élément à retenir", + "requiredError": "Saisissez la substance.", + "savedToast": "Allergie enregistrée", + "updatedToast": "Allergie mise à jour", + "saveError": "Impossible d'enregistrer l'allergie." + } + }, + "family": { + "cardTitle": "Antécédents familiaux", + "cardDescription": "Maladies de vos proches, une entrée par maladie.", + "description": "Enregistrer une maladie d'un proche.", + "add": "Ajouter une entrée", + "addFirst": "Ajoutez votre première entrée", + "addTitle": "Ajouter un antécédent familial", + "editTitle": "Modifier l'antécédent familial", + "formDescription": "Le lien de parenté et la maladie sont stockés en clair ; la note est chiffrée.", + "emptyTitle": "Aucun antécédent familial", + "emptyDescription": "Ajoutez les maladies présentes dans votre famille.", + "loadError": "Impossible de charger vos antécédents familiaux.", + "deletedToast": "Entrée supprimée", + "deleteError": "Impossible de supprimer l'entrée.", + "deleteConfirmTitle": "Supprimer cette entrée ?", + "deleteConfirmDescription": "Cela supprime l'enregistrement. C'est irréversible.", + "ageAtOnsetShort": "Âge {age}", + "relationship": { + "MOTHER": "Mère", + "FATHER": "Père", + "SISTER": "Sœur", + "BROTHER": "Frère", + "DAUGHTER": "Fille", + "SON": "Fils", + "GRANDMOTHER_MATERNAL": "Grand-mère (maternelle)", + "GRANDFATHER_MATERNAL": "Grand-père (maternel)", + "GRANDMOTHER_PATERNAL": "Grand-mère (paternelle)", + "GRANDFATHER_PATERNAL": "Grand-père (paternel)", + "AUNT": "Tante", + "UNCLE": "Oncle", + "COUSIN": "Cousin/cousine", + "HALF_SIBLING": "Demi-frère/demi-sœur", + "OTHER": "Autre proche" + }, + "form": { + "relationship": "Proche", + "condition": "Maladie", + "conditionPlaceholder": "p. ex. Diabète de type 2, Cancer du sein", + "ageAtOnset": "Âge d'apparition", + "ageAtOnsetPlaceholder": "p. ex. 55", + "note": "Note", + "notePlaceholder": "Tout autre élément à retenir", + "requiredError": "Saisissez la maladie.", + "ageError": "Saisissez un âge entre 0 et 120.", + "savedToast": "Entrée enregistrée", + "updatedToast": "Entrée mise à jour", + "saveError": "Impossible d'enregistrer l'entrée." + } + } + }, + "environment": { + "fields": { + "tempMean": "Température quotidienne", + "tempMin": "Minimale nocturne", + "sunshine": "Ensoleillement", + "daylight": "Durée du jour", + "precip": "Précipitations", + "pressureMean": "Pression atmosphérique", + "pressureDelta": "Variation de pression" + } + }, + "documents": { + "title": "Documents entrants", + "subtitle": "Importez un document clinique et vérifiez les données que nous y avons lues.", + "disclaimer": { + "title": "Nous lisons, nous n'interprétons pas", + "body": "Nous transcrivons uniquement ce qu'indique votre document — valeurs, codes et dates — et nous ne posons aucun diagnostic, n'interprétons et ne signalons rien. Rien n'est enregistré tant que vous ne l'avez pas vérifié et approuvé. Seul votre professionnel de santé peut interpréter ces résultats." + }, + "upload": { + "label": "Importer un document", + "kindLabel": "Type de document", + "button": "Importer", + "uploading": "Lecture…", + "fileLabel": "Fichier du document" + }, + "kind": { + "DOCTOR_REPORT": "Compte rendu médical", + "DISCHARGE_LETTER": "Lettre de sortie", + "OTHER": "Autre" + }, + "list": { + "empty": "Aucun document pour l'instant." + }, + "status": { + "EXTRACTING": "Lecture", + "EXTRACTED": "Prêt à vérifier", + "FAILED": "Lecture impossible", + "CONFIRMED": "Vérifié", + "DISCARDED": "Écarté" + }, + "review": { + "title": "Vérifier les données extraites", + "discard": "Écarter", + "confirm": "Enregistrer les données approuvées", + "confirming": "Enregistrement…", + "needsReview": "À vérifier", + "approve": "Approuver", + "reject": "Rejeter", + "edit": "Modifier", + "cancel": "Annuler", + "save": "Enregistrer" + }, + "empty": { + "noFacts": "Plus rien à vérifier." + }, + "factType": { + "CONDITION": "Diagnostic", + "OBSERVATION": "Valeur", + "MEDICATION_STATEMENT": "Médicament" + }, + "fields": { + "label": "Nom", + "value": "Valeur", + "unit": "Unité", + "date": "Date", + "dose": "Dose" + }, + "toast": { + "uploaded": "Document lu. Vérifiez les données ci-dessous.", + "uploadFailed": "Lecture du document impossible.", + "confirmed": "Données approuvées enregistrées.", + "confirmFailed": "Impossible d'enregistrer vos décisions.", + "saved": "Donnée mise à jour.", + "discarded": "Document écarté." + } + }, + "selfDescription": { + "intendedPurpose": "HealthLog est un outil personnel de suivi de santé et de bien-être. Il vous aide à enregistrer, visualiser et exporter vos propres données de santé. Il ne diagnostique, ne traite, ne guérit ni ne prévient aucune maladie, et n'est pas un dispositif médical.", + "coach": "Le Coach s'appuie sur vos propres données enregistrées. Ce n'est pas un médecin et il ne donne pas de conseil médical : pour un diagnostic ou un traitement, consultez un professionnel de santé.", + "insights": "Ce sont des tendances descriptives calculées à partir de vos propres données enregistrées, pas une évaluation clinique ni un diagnostic.", + "labs": "Ce sont vos propres relevés, pas un diagnostic médical. Les valeurs de référence varient selon le laboratoire et la personne : seul votre médecin peut les interpréter pour vous.", + "medications": "Ceci suit les médicaments que vous enregistrez et votre propre observance. Cela n'évalue ni les doses ni les interactions et n'est pas un conseil médical : confirmez-les avec votre pharmacien ou votre médecin.", + "emergency": "Si cela pourrait être une urgence, contactez maintenant votre numéro d'urgence local ou un professionnel de santé. HealthLog ne peut pas aider en cas d'urgence.", + "dataPosture": "HealthLog est auto-hébergé : vos données de santé restent sur votre propre instance, chiffrées au repos. Les fonctions d'IA utilisent une clé de fournisseur que vous apportez ou un modèle local, donc rien ne quitte votre instance au-delà du fournisseur que vous configurez, et il n'y a aucune analyse tierce de vos données de santé." } } diff --git a/messages/it.json b/messages/it.json index 60e5288d4..7d47c79f9 100644 --- a/messages/it.json +++ b/messages/it.json @@ -35,7 +35,8 @@ "showPassword": "Mostra password", "hidePassword": "Nascondi password", "learnMore": "Scopri di più", - "dismiss": "Ignora" + "dismiss": "Ignora", + "moreInfo": "Maggiori informazioni" }, "doctorReport": { "title": "Rapporto sanitario", @@ -238,7 +239,9 @@ "cycle": "Ciclo", "labs": "Esami", "vorsorge": "Controlli", - "illness": "Malattia" + "illness": "Malattia", + "documents": "Documenti", + "mentalWellbeing": "Benessere mentale" }, "auth": { "login": "Accedi", @@ -574,7 +577,15 @@ "bulkDeleteSuccess": "{count} misurazioni eliminate", "bulkDeleteError": "Errore durante l'eliminazione delle misurazioni selezionate", "bulkDeleteConfirmTitle": "Eliminare {count} misurazioni?", - "bulkDeleteConfirmBody": "Le {count} misurazioni selezionate verranno eliminate definitivamente." + "bulkDeleteConfirmBody": "Le {count} misurazioni selezionate verranno eliminate definitivamente.", + "typePhq9Score": "Punteggio PHQ-9", + "typeGad7Score": "Punteggio GAD-7", + "typeGripStrength": "Forza di presa", + "typePainNrs": "Dolore (0–10)", + "typeWaistCircumference": "Circonferenza vita", + "typeWaistToHeight": "Rapporto vita-altezza", + "unitScore": "pti", + "unitRatio": "rapporto" }, "mood": { "title": "Umore", @@ -750,7 +761,8 @@ "sectionFactors": "Fattori", "sectionNoteDetails": "Nota e dettagli", "quickTagsHelp": "I tuoi tag frequenti: un tocco", - "quickTagsExpand": "Altro" + "quickTagsExpand": "Altro", + "loadError": "Impossibile caricare le voci dell'umore." }, "medications": { "title": "Farmaci", @@ -1852,7 +1864,20 @@ "sat": "Sabato", "sun": "Domenica" }, - "rangeBand": "Intervallo" + "rangeBand": "Intervallo", + "a11y": { + "summary": "Grafico {metric}. Intervallo da {min} a {max}, ultimo {latest}, tendenza {trend}.", + "noData": "Grafico {metric}. Nessun dato disponibile.", + "trendUp": "in aumento", + "trendDown": "in calo", + "trendFlat": "stabile", + "scatterSummary": "Grafico di correlazione, {count} punti che confrontano {xLabel} con {yLabel}.", + "scatterNoData": "Grafico di correlazione di {xLabel} e {yLabel}. Nessun dato disponibile.", + "moodHeatmap": "Mappa di calore dell'umore dal {start} al {end}, {days} giorni registrati.", + "moodHeatmapEmpty": "Mappa di calore dell'umore, nessun giorno ancora registrato.", + "complianceHeatmap": "Mappa di calore dell'aderenza ai farmaci dal {start} al {end}, {days} giorni attivi.", + "complianceHeatmapEmpty": "Mappa di calore dell'aderenza ai farmaci, nessun giorno attivo." + } }, "chart": { "overlay": { @@ -2037,7 +2062,11 @@ "noProviderTitle": "Nessun provider IA collegato", "noProviderDescription": "Il briefing giornaliero riassume le tue misurazioni in linguaggio chiaro. HealthLog ha bisogno di un provider IA per questo: usa la tua chiave (OpenAI, Anthropic) o un modello locale. I tuoi dati restano tuoi.", "noProviderAction": "Apri impostazioni IA", - "staleNoProviderHint": "Questo briefing non può aggiornarsi: nessun provider IA è collegato." + "staleNoProviderHint": "Questo briefing non può aggiornarsi: nessun provider IA è collegato.", + "refreshFailedHint": "Impossibile aggiornare il briefing: viene mostrato l'ultimo.", + "retryAction": "Riprova", + "failedTitle": "Impossibile generare il briefing", + "failedDescription": "Il provider IA ha superato il tempo limite o ha restituito un errore. Riprova oppure aumenta il timeout di risposta nelle impostazioni IA per un modello locale lento." }, "trendsRow": { "title": "Tendenze", @@ -2372,7 +2401,8 @@ "correlationMoodPulse": "C'è un legame reale tra il mio umore e il mio battito? Aiutami a interpretare questa correlazione.", "correlationWeightWeekday": "Il mio peso segue uno schema settimanale? Cosa mi dice questa correlazione per giorno della settimana?", "recommendation": "Dimmi di più su questa raccomandazione: \"{text}\" — perché si applica a me e cosa dovrei fare?" - } + }, + "assessmentPrompt": "Spiegami la mia valutazione di {metric}: cosa significa e cosa dovrei fare?" }, "relativeJustNow": "proprio ora", "relativeMinutesAgoOne": "{count} minuto fa", @@ -2444,7 +2474,8 @@ "emptyState": { "title": "Ancora nessuna FC a riposo", "description": "La FC a riposo arriva quando Apple Watch o un Withings compatibile ha indossato una giornata tranquilla." - } + }, + "coachPrefill": "Non ho ancora registrato alcuna frequenza cardiaca a riposo: perché il valore giornaliero a riposo è diverso da un battito occasionale e come posso migliorarlo?" }, "oxygenSaturation": { "title": "Saturazione di ossigeno", @@ -2670,7 +2701,8 @@ "caption": "Deficit cumulato nelle ultime {nights} notti", "clearValue": "In pari", "clearCaption": "Nessun deficit accumulato nelle ultime {nights} notti", - "learning": "Stiamo ancora misurando il tuo sonno — {counted} notti finora, ne mancano {remaining} per un bilancio affidabile." + "learning": "Stiamo ancora misurando il tuo sonno — {counted} notti finora, ne mancano {remaining} per un bilancio affidabile.", + "computedInfo": "Questa è la stima propria di HealthLog: un bilancio mobile tra fabbisogno e sonno delle ultime notti, che il sonno di recupero riduce. Può differire dal dato di debito di sonno nativo di un dispositivo indossabile, che usa un proprio modello di fabbisogno." }, "average": { "title": "Media per notte", @@ -3585,28 +3617,7 @@ }, "sleepQuality": { "title": "Qualità del sonno", - "subtitle": "Punteggi notturni del tuo wearable", - "assessment": { - "metric": { - "SLEEP_SCORE": "il tuo punteggio di sonno complessivo", - "SLEEP_EFFICIENCY": "l’efficienza del sonno", - "SLEEP_PERFORMANCE": "la prestazione del sonno", - "SLEEP_CONSISTENCY": "la regolarità del sonno" - }, - "grade": { - "excellent": "eccellente", - "good": "solida", - "fair": "discreta", - "low": "piuttosto bassa" - }, - "clause": "Nelle ultime 30 notti {metric} è {grade}, a {value}.", - "closing": { - "excellent": "Mantieni la routine che ti porta fin qui.", - "good": "Orari di sonno e risveglio più stabili sono di solito il prossimo guadagno.", - "fair": "Proteggere una finestra di sonno costante è ciò che alza di più questo valore.", - "low": "Una routine serale regolare e un orario di sveglia fisso sono le prime leve." - } - } + "subtitle": "Punteggi notturni del tuo wearable" }, "recovery": { "title": "Recupero", @@ -3666,7 +3677,86 @@ }, "updatedTodayAt": "Aggiornato oggi, {time}", "updatedYesterday": "Aggiornato ieri", - "updatedOn": "Aggiornato il {date}" + "updatedOn": "Aggiornato il {date}", + "healthStatus": { + "sectionTitle": "Cosa è cambiato", + "subtitle": "Segnali che si discostano dal tuo intervallo abituale.", + "deviationAbove": "{vital} è sopra il tuo intervallo abituale.", + "deviationBelow": "{vital} è sotto il tuo intervallo abituale.", + "shiftUp": "{vital} si è spostato verso l'alto rispetto alla tua norma recente.", + "shiftDown": "{vital} si è spostato verso il basso rispetto alla tua norma recente.", + "rangeContext": "Ora {value}, di solito {low}–{high}.", + "shiftContext": "Circa {before} prima, {after} da allora." + }, + "breathingScreening": { + "sectionTitle": "Respirazione durante il sonno", + "subtitle": "Un segnale di screening dai tuoi dati del sonno.", + "classificationElevated": "Il tuo dispositivo ha segnalato disturbi respiratori elevati.", + "classificationNotElevated": "La classificazione del tuo dispositivo non è elevata.", + "trendUp": "In aumento rispetto alle notti precedenti.", + "trendDown": "In calo rispetto alle notti precedenti.", + "trendStable": "Stabile rispetto alle notti precedenti.", + "nights": "{count} notti registrate", + "indexLine": "indice medio recente {value}", + "eventsFlagged": "{count} eventi segnalati dal tuo dispositivo", + "disclaimer": "È un segnale di screening, non una diagnosi: parlane con un medico." + }, + "labsChanges": { + "sectionTitle": "Dal tuo ultimo pannello", + "subtitle": "Come i tuoi ultimi risultati si confrontano con il pannello precedente.", + "statusInRange": "nell'intervallo", + "statusBelow": "sotto l'intervallo", + "statusAbove": "sopra l'intervallo", + "directionFlat": "nessuna variazione" + }, + "navGripStrength": "Forza di presa", + "navPainNrs": "Dolore", + "navWaistCircumference": "Vita", + "navWaistToHeight": "Vita/altezza", + "gripStrength": { + "title": "Forza di presa", + "description": "Forza massima di presa della mano: un indicatore robusto della forza di tutto il corpo. Sotto la soglia specifica per sesso (uomini 27 kg, donne 16 kg) segnala una probabile sarcopenia; il tuo andamento personale guida la lettura.", + "chartTitle": "Forza di presa", + "emptyState": { + "title": "Ancora nessuna forza di presa", + "description": "Registra una misura con un dinamometro manuale (annota la migliore di alcune strette) e l'andamento apparirà qui.", + "cta": "Registra la forza di presa" + }, + "coachPrefill": "Non ho ancora registrato la forza di presa — cosa dice sulla salute dei miei muscoli e come la misuro e la miglioro?" + }, + "pain": { + "title": "Dolore (0–10)", + "description": "Intensità del dolore riferita dal paziente sulla scala numerica 0–10, dove 0 è nessun dolore e 10 il peggiore immaginabile. Una variazione costante di 2 punti è il segnale da osservare.", + "chartTitle": "Dolore (0–10)", + "emptyState": { + "title": "Ancora nessun punteggio del dolore", + "description": "Registra come senti il dolore in questo momento sulla scala 0–10 e l'andamento apparirà qui.", + "cta": "Registra un punteggio del dolore" + }, + "coachPrefill": "Non ho ancora registrato punteggi del dolore — come dovrei monitorare il dolore sulla scala 0–10 e cosa conta come variazione significativa?" + }, + "waist": { + "title": "Circonferenza vita", + "description": "Circonferenza della vita in centimetri: un marcatore del grasso addominale. La soglia di rischio aumentato dell'OMS dipende dal sesso (uomini 94 cm, donne 80 cm); il tuo andamento personale guida la lettura.", + "chartTitle": "Circonferenza vita", + "emptyState": { + "title": "Ancora nessuna misura della vita", + "description": "Misura la vita nel punto medio tra l'ultima costola e l'osso dell'anca, a fine espirazione normale, e registrala qui.", + "cta": "Registra la circonferenza vita" + }, + "coachPrefill": "Non ho ancora registrato misure della vita — come misuro la vita correttamente e cosa significa il numero per la mia salute?" + }, + "waistToHeight": { + "title": "Rapporto vita-altezza", + "description": "La tua vita divisa per la tua altezza: il singolo numero più robusto tra le popolazioni. Tenerlo sotto 0,5 (vita inferiore alla metà dell'altezza) è l'obiettivo semplice.", + "chartTitle": "Rapporto vita-altezza", + "emptyState": { + "title": "Ancora nessun rapporto vita-altezza", + "description": "Registra un rapporto vita-altezza e l'andamento apparirà qui. Regola pratica: tieni la vita sotto la metà della tua altezza.", + "cta": "Registra il rapporto vita-altezza" + }, + "coachPrefill": "Non ho ancora registrato un rapporto vita-altezza — perché è un numero utile e come lo tengo sotto 0,5?" + } }, "thresholds": { "defaultLabel": "Predefinito", @@ -4012,7 +4102,8 @@ "skinTemperature": "Temperatura cutanea", "respiratoryRate": "Frequenza respiratoria", "recovery": "Recupero", - "stress": "Stress" + "stress": "Stress", + "sleepDebt": "Debito di sonno" }, "sourceLabels": { "WITHINGS": "Withings", @@ -4235,6 +4326,50 @@ "mcp": { "title": "Connettore MCP", "subtitle": "Collega un assistente esterno ai tuoi dati sulla salute." + }, + "anamnesis": { + "title": "Anamnesi", + "subtitle": "Gestisci le tue allergie e la tua anamnesi familiare.", + "description": "Record strutturati di allergie e anamnesi familiare." + }, + "environment": { + "title": "Ambiente", + "subtitle": "Contesto meteo, luce del giorno e posizione.", + "disabledHint": "Attiva il modulo Ambiente per impostare una posizione principale e integrare il meteo e le ore di luce giornaliere.", + "searchPlaceholder": "Cerca una città", + "search": "Cerca", + "searchError": "Impossibile cercare quel luogo.", + "use": "Usa", + "select": "Seleziona", + "saveError": "Impossibile salvare la modifica.", + "homeSaved": "Posizione principale salvata.", + "travelAdded": "Periodo di posizione aggiunto.", + "travelRemoved": "Periodo di posizione rimosso.", + "backfillQueued": "Recupero pianificato.", + "backfillError": "Impossibile avviare il recupero.", + "startDate": "Data di inizio", + "endDate": "Data di fine", + "storedDays": "{count} giorni di meteo salvati.", + "home": { + "title": "Posizione principale", + "description": "Una posizione approssimativa a livello di città, mai un tracciato preciso. Meteo e luce diurna vengono recuperati per questo luogo nei giorni senza un periodo di posizione, a partire dalla data in cui lo imposti.", + "current": "Attuale: {label}", + "none": "Nessuna posizione principale impostata.", + "since": "Meteo registrato dal {date}. I giorni precedenti restano vuoti finché non aggiungi un periodo di posizione." + }, + "travel": { + "title": "Periodi di posizione", + "description": "Aggiungi un luogo e un intervallo di date: un viaggio o un tratto della tua cronologia passata. Il periodo prevale sulla tua residenza nei giorni che copre, così il meteo corrisponde a dove ti trovavi davvero. È così che correggi il passato.", + "none": "Nessun periodo di posizione.", + "add": "Aggiungi {label}", + "remove": "Rimuovi periodo di posizione" + }, + "backfill": { + "title": "Recupera i giorni passati", + "description": "Recupera e salva il meteo di un intervallo di date passato così i giorni più vecchi entrano nelle correlazioni. L'inizio è impostato in modo predefinito sulla data di validità della tua residenza; per andare più indietro, aggiungi prima un periodo di posizione per quell'intervallo.", + "run": "Recupera" + }, + "noResults": "Nessun luogo corrispondente trovato." } }, "profile": "Profilo", @@ -6076,7 +6211,13 @@ "statusOff": "Disattivato", "enabledToast": "Avvisi proattivi del Coach attivati.", "disabledToast": "Avvisi proattivi del Coach disattivati.", - "saveError": "Impossibile salvare. Riprova più tardi." + "saveError": "Impossibile salvare. Riprova più tardi.", + "suggestionsLabel": "Suggerimenti di esempio", + "suggestionsDesc": "Mostra di tanto in tanto una domanda di esempio pronta all'uso nella schermata del Coach.", + "suggestionsAria": "Attiva o disattiva i suggerimenti di esempio del Coach", + "aiComposedLabel": "Lascia che sia il Coach a formulare il promemoria", + "aiComposedDesc": "Quando è disponibile un provider, il promemoria proattivo viene scritto dal modello anziché dal modello di testo fisso. Disattivato per impostazione predefinita.", + "aiComposedAria": "Attiva o disattiva il promemoria proattivo scritto dall'IA" }, "lowStock": { "title": "Avviso scorte basse", @@ -6173,7 +6314,9 @@ "resetToDefault": "Ripristina predefinito", "saveSuccess": "Range obiettivo aggiornato", "saveError": "Impossibile salvare il range obiettivo", - "resetSuccess": "Range obiettivo ripristinato" + "resetSuccess": "Range obiettivo ripristinato", + "validationOrder": "Il minimo deve essere inferiore al massimo.", + "validationBounds": "Inserisci valori entro l'intervallo consentito indicato sotto." }, "currentValue": "Attuale: {value} {unit}", "targetRangeValue": "Range obiettivo: {min}–{max} {unit}", @@ -6305,21 +6448,18 @@ "dailyBody": "Come ti senti oggi?" }, "coachNudges": { - "complianceTitle": "Il tuo Coach ha un'osservazione", - "complianceBody": "Le tue assunzioni sono state discontinue questa settimana. Parla un attimo con il Coach: forse il piano si può semplificare.", - "bpTitle": "Pressione elevata questa settimana", - "bpBody": "La tua pressione media di questa settimana ha superato il tuo obiettivo. Il Coach può aiutarti a contestualizzare i valori.", - "scoreTitle": "Il tuo punteggio di recupero sta calando", - "scoreBody": "Il tuo punteggio di recupero è calato sensibilmente questa settimana. Il Coach può aiutarti a circoscrivere le possibili cause.", - "selfContextTitle": "Aiuta il tuo Coach a conoscerti meglio", - "selfContextBody": "La tua autodescrizione è incompleta o non viene aggiornata da tempo. Due minuti nelle impostazioni mantengono fresco il contesto del Coach.", - "weightTitle": "Il tuo peso si sta allontanando dall'obiettivo", - "weightBody": "La tua media settimanale si è allontanata ulteriormente dal tuo intervallo obiettivo. Il Coach può aiutarti a fare il punto con calma.", - "sleepDebtTitle": "Diverse notti brevi questa settimana", - "sleepDebtBody": "La maggior parte delle tue ultime notti è rimasta sotto il tuo obiettivo di sonno. Il Coach può aiutarti a cercare schemi ricorrenti.", - "measurementGapTitle": "Il tuo diario è rimasto in silenzio", - "measurementGapBody": "Da una settimana non arrivano nuovi valori. Due parole con il Coach aiutano a riprendere la routine — o a scoprire una sincronizzazione interrotta.", - "focusSuffix": "Volevi tenere d'occhio: {focus}.", + "greetingMorning": "Buongiorno", + "greetingMorningNamed": "Buongiorno, {name}", + "greetingHey": "Ciao", + "greetingHeyNamed": "Ciao, {name}", + "complianceBody": "Questa settimana le tue assunzioni sono andate un po' a singhiozzo. Se vuoi, vediamo insieme cosa potrebbe semplificartele.", + "bpBody": "La tua pressione è rimasta questa settimana un po' sopra il tuo intervallo abituale. Quando ti va, la guardo volentieri con te.", + "scoreBody": "Il tuo recupero sta calando un po' in questi ultimi giorni. Se ti incuriosisce il motivo, sono qui per parlarne.", + "selfContextBody": "È da un po' che non aggiorni la tua autodescrizione. Quando hai un momento, rinfrescarla mi aiuta a capirti meglio.", + "weightBody": "Ultimamente il tuo peso si è un po' allontanato dal tuo intervallo obiettivo. Se vuoi, facciamo il punto insieme, con calma.", + "sleepDebtBody": "Diverse delle tue notti sono state un po' corte questa settimana. Se ti va, cerchiamo insieme uno schema.", + "measurementGapBody": "Il tuo diario è in silenzio da circa una settimana. Quando ti è comodo, due parole aiutano a riprendere.", + "focusNote": "Si lega anche a ciò che volevi tenere d'occhio.", "questionConditions": "Hai malattie croniche che il Coach dovrebbe conoscere?", "questionFocus": "C'è qualcosa di specifico a cui il Coach dovrebbe fare attenzione?", "questionAllergies": "Hai allergie o intolleranze?", @@ -6358,6 +6498,11 @@ "label": "Passphrase", "error": "La passphrase non è corretta.", "submit": "Sblocca" + }, + "loadError": { + "title": "Impossibile caricare questa vista condivisa", + "description": "Si è verificato un errore durante l'apertura di questo referto sanitario condiviso. Il link potrebbe essere scaduto oppure si è verificato un problema temporaneo. Riprova o chiedi un nuovo link alla persona che lo ha condiviso.", + "retry": "Riprova" } }, "cycle": { @@ -6831,7 +6976,8 @@ "empty": "Nessun promemoria di controllo in arrivo.", "openLink": "Apri controlli" }, - "showMeasurements": "Mostra le misurazioni" + "showMeasurements": "Mostra le misurazioni", + "loadError": "Impossibile caricare i promemoria." }, "moduleList": { "viewToggleLabel": "Vista", @@ -6942,7 +7088,7 @@ "deletedToast": "Biomarcatore eliminato", "deleteError": "Impossibile eliminare il biomarcatore", "deleteConfirmTitle": "Eliminare il biomarcatore?", - "deleteConfirmDescription": "La definizione viene rimossa. Le misurazioni esistenti restano ma vengono scollegate.", + "deleteConfirmDescription": "Il biomarcatore e tutti i suoi valori registrati vengono eliminati definitivamente. L'operazione non è reversibile.", "emptyTitle": "Ancora nessun biomarcatore", "emptyDescription": "Definisci marcatori come LDL o HbA1c per registrare i valori.", "loadError": "Impossibile caricare i biomarcatori", @@ -6977,7 +7123,14 @@ "unhiddenToast": "Biomarcatore ripristinato", "hideError": "Impossibile aggiornare il biomarcatore", "allHidden": "Tutti i biomarcatori sono nascosti.", - "hiddenSection": "Nascosti" + "hiddenSection": "Nascosti", + "targetRange": { + "title": "Regola l'intervallo obiettivo", + "description": "Imposta il riferimento minimo e massimo per questo marcatore. Lascia un campo vuoto per un limite aperto.", + "save": "Salva intervallo", + "savedToast": "Intervallo obiettivo aggiornato", + "saveError": "Impossibile aggiornare l'intervallo obiettivo" + } }, "catalog": { "total-cholesterol": "Colesterolo totale", @@ -7044,8 +7197,16 @@ "hemoglobin": "La proteina dei globuli rossi che trasporta l'ossigeno.", "hematocrit": "La frazione del volume sanguigno costituita da globuli rossi.", "wbc": "Conteggio totale dei globuli bianchi, la difesa circolante del sistema immunitario.", - "platelets": "Frammenti cellulari che si aggregano per formare coaguli di sangue." - } + "platelets": "Frammenti cellulari che si aggregano per formare coaguli di sangue.", + "apob": "Conta direttamente le particelle aterogene; spesso un marcatore di rischio migliore del LDL-C quando i due discordano.", + "lp-a": "Fattore di rischio in gran parte ereditario e permanente — di solito misurato una volta. Riportare in unità molari (nmol/L).", + "fasting-insulin": "Si abbina alla glicemia a digiuno per stimare la resistenza insulinica (HOMA-IR).", + "omega-3-index": "Quota di EPA+DHA nei globuli rossi; 8–12% è l'intervallo cardioprotettivo comunemente citato." + }, + "apob": "Apolipoproteina B (ApoB)", + "lp-a": "Lipoproteina(a)", + "fasting-insulin": "Insulina a digiuno", + "omega-3-index": "Indice omega-3" }, "noteLoadFailed": "Impossibile caricare la nota; resterà invariata al salvataggio.", "showingLatestOf": "Visualizzazione degli ultimi {shown} risultati su {total}", @@ -7092,7 +7253,8 @@ "fileType": "Carica un JPEG, PNG, WebP o PDF.", "extractFailed": "Impossibile leggere il referto. Prova con una foto più nitida.", "pdfNeedsAnthropic": "La scansione PDF richiede un provider di visione Claude; carica invece una foto." - } + }, + "loadMoreReadings": "Carica altre misurazioni" }, "modules": { "cycle": { @@ -7150,6 +7312,18 @@ "mcp": { "label": "Accesso assistente esterno (MCP)", "description": "Consente a un assistente compatibile con MCP di leggere i tuoi dati sanitari tramite un token con ambito limitato. Disattivato per impostazione predefinita." + }, + "environment": { + "label": "Ambiente", + "description": "Integra il meteo e le ore di luce giornaliere della tua posizione per correlarli con umore, sonno e parametri vitali. Disattivato di default." + }, + "inboundDocuments": { + "label": "Documenti in entrata", + "description": "Carica un referto medico o una lettera di dimissione; rivedi i dati estratti prima di salvarli. Disattivato per impostazione predefinita." + }, + "mentalHealth": { + "label": "Benessere mentale", + "description": "Questionari PHQ-9 e GAD-7 facoltativi, accanto al diario dell'umore. Disattivato per impostazione predefinita; le risposte sono cifrate e tenute fuori dal Coach IA." } }, "illness": { @@ -7202,8 +7376,8 @@ "note": "Nota", "notePlaceholder": "Qualcosa da ricordare (cifrato).", "saveError": "Impossibile salvare l'episodio. Riprova.", - "parent": "Part of", - "parentNone": "Not linked" + "parent": "Parte di", + "parentNone": "Non collegato" }, "sheet": { "title": "Registra {date}", @@ -7284,35 +7458,36 @@ "fever": "Temperatura: {value} °C", "empty": "Nessun giorno registrato finora." }, - "loadError": "Could not load this episode.", - "backToList": "Back to journal", + "loadError": "Impossibile caricare questo episodio.", + "backToList": "Torna al diario", "detail": { "note": "Note" }, "edit": { - "title": "Edit episode", - "description": "Update this episode's details." + "title": "Modifica episodio", + "description": "Aggiorna i dettagli di questo episodio." }, "menu": { - "label": "Episode actions" + "label": "Azioni dell'episodio" }, "deleteConfirm": { - "title": "Delete this episode?", - "body": "“{label}” and all its day-logs will be removed. This cannot be undone." + "title": "Eliminare questo episodio?", + "body": "«{label}» e tutti i relativi registri giornalieri verranno rimossi. L'operazione non può essere annullata." }, "vital": { - "RESTING_HEART_RATE": "Resting heart rate", - "HEART_RATE_VARIABILITY": "Heart-rate variability", + "RESTING_HEART_RATE": "Frequenza cardiaca a riposo", + "HEART_RATE_VARIABILITY": "Variabilità della frequenza cardiaca", "RECOVERY_SCORE": "Recovery", - "OXYGEN_SATURATION": "Oxygen saturation", - "BODY_TEMPERATURE": "Body temperature", - "SKIN_TEMPERATURE": "Skin temperature", - "RESPIRATORY_RATE": "Respiratory rate", - "BLOOD_PRESSURE_SYS": "Systolic blood pressure", - "BLOOD_PRESSURE_DIA": "Diastolic blood pressure", + "OXYGEN_SATURATION": "Saturazione di ossigeno", + "BODY_TEMPERATURE": "Temperatura corporea", + "SKIN_TEMPERATURE": "Temperatura cutanea", + "RESPIRATORY_RATE": "Frequenza respiratoria", + "BLOOD_PRESSURE_SYS": "Pressione arteriosa sistolica", + "BLOOD_PRESSURE_DIA": "Pressione arteriosa diastolica", "PULSE": "Pulse", "WEIGHT": "Weight" - } + }, + "listLoadError": "Impossibile caricare gli episodi." }, "coach": { "reminderSuggestion": { @@ -7372,5 +7547,318 @@ "glucoseHighDoctor": "Due misurazioni recenti erano di {value} mg/dL o superiori. Rimisura, idratati e contatta il medico al più presto se resta così alta.", "glucoseHighEmergency": "Due misurazioni recenti erano di {value} mg/dL o superiori e hai segnalato sintomi. In caso di vomito, respiro affannoso o confusione, chiama subito i soccorsi." } + }, + "version": { + "updateAvailable": { + "title": "È disponibile una nuova versione", + "reload": "Ricarica", + "dismiss": "Ignora" + } + }, + "mentalHealth": { + "pageTitle": "Check-in sul benessere mentale", + "pageDescription": "Due autovalutazioni validate e facoltative — PHQ-9 (umore) e GAD-7 (preoccupazione) — accanto al tuo diario dell'umore. Ciò che conta è l'andamento nel tempo, non un singolo numero.", + "disclaimer": "Questa è un'autovalutazione di screening, non una diagnosi. Un punteggio alto o basso non significa che tu abbia o non abbia un disturbo. Solo un professionista sanitario qualificato può fare una diagnosi. In caso di dubbi, parlane con il tuo medico.", + "optInNote": "Facoltativo e privato: le tue risposte individuali sono cifrate, tenute fuori dal Coach IA e dalle esportazioni, e non vengono mai condivise.", + "attributionLabel": "Fonte", + "choosePrompt": "Scegli un check-in", + "start": "Inizia", + "back": "Indietro", + "stem": "Nelle ultime 2 settimane, con quale frequenza ti hanno disturbato i seguenti problemi?", + "submit": "Vedi il mio risultato", + "submitting": "Salvataggio…", + "saved": "Salvato", + "error": "Qualcosa è andato storto. Riprova.", + "considerProfessional": "Il tuo punteggio è in un intervallo in cui parlare con un professionista sanitario può aiutare. È un suggerimento gentile, non una diagnosi.", + "instrument": { + "phq9": "PHQ-9 — umore", + "gad7": "GAD-7 — preoccupazione" + }, + "instrumentSub": { + "phq9": "Screening della depressione, 9 domande", + "gad7": "Screening dell'ansia, 7 domande" + }, + "options": { + "0": "Per niente", + "1": "Alcuni giorni", + "2": "Più della metà dei giorni", + "3": "Quasi ogni giorno" + }, + "functionalTitle": "Se hai indicato dei problemi, quanto ti hanno reso difficile lavorare, occuparti della casa o andare d'accordo con gli altri?", + "functional": { + "0": "Per niente difficile", + "1": "Un po' difficile", + "2": "Molto difficile", + "3": "Estremamente difficile" + }, + "result": { + "title": "Il tuo risultato", + "totalLabel": "Punteggio totale", + "bandLabel": "Intervallo per questo screening", + "takeAnother": "Fai un altro check-in" + }, + "band": { + "PHQ9": { + "minimal": "Nessuna / minima", + "mild": "Lieve", + "moderate": "Moderata", + "modSevere": "Moderatamente grave", + "severe": "Grave" + }, + "GAD7": { + "minimal": "Nessuna / minima", + "mild": "Lieve", + "moderate": "Moderata", + "severe": "Grave" + } + }, + "history": { + "title": "La tua cronologia", + "empty": "Ancora nessun check-in. Il tuo andamento apparirà qui dopo che ne avrai completato uno." + }, + "crisis": { + "title": "Non devi affrontare tutto questo da solo/a", + "intro": "Hai indicato pensieri che sarebbe meglio essere morto/a o di farti del male. Un supporto è disponibile proprio ora, e chiedere aiuto è un segno di forza.", + "ifDanger": "Se sei in pericolo immediato, chiama subito il {emergency}.", + "resourcesTitle": "Persone che possono aiutare" + }, + "items": { + "phq9": { + "1": "Scarso interesse o piacere nel fare le cose", + "2": "Sentirsi giù di morale, depresso/a o disperato/a", + "3": "Difficoltà ad addormentarsi o a mantenere il sonno, oppure dormire troppo", + "4": "Sentirsi stanco/a o avere poca energia", + "5": "Scarso appetito o mangiare troppo", + "6": "Avere una cattiva opinione di sé, o sentirsi un/una fallito/a o aver deluso se stesso/a o i propri familiari", + "7": "Difficoltà a concentrarsi, ad esempio nel leggere il giornale o nel guardare la televisione", + "8": "Muoversi o parlare così lentamente che altre persone potrebbero averlo notato? Oppure, al contrario, essere così agitato/a o irrequieto/a da muoversi molto più del solito", + "9": "Pensare che sarebbe meglio morire o di farsi del male in qualche modo" + }, + "gad7": { + "1": "Sentirsi nervoso/a, ansioso/a o teso/a", + "2": "Non riuscire a smettere di preoccuparsi o a controllare le preoccupazioni", + "3": "Preoccuparsi troppo per cose diverse", + "4": "Avere difficoltà a rilassarsi", + "5": "Essere così irrequieto/a da far fatica a stare seduto/a fermo/a", + "6": "Infastidirsi o irritarsi facilmente", + "7": "Avere paura come se potesse accadere qualcosa di terribile" + } + }, + "crisisResource": { + "telefonSeelsorge": { + "name": "TelefonSeelsorge (24/7, gratuito e riservato)" + }, + "nummerGegenKummer": { + "name": "Nummer gegen Kummer (per i giovani)" + }, + "krisenchat": { + "name": "krisenchat (chat 24/7, per i giovani)" + }, + "findahelpline": { + "name": "Find a Helpline (elenco mondiale)" + }, + "euEmotionalSupport": { + "name": "Linea di supporto emotivo" + }, + "lifeline988": { + "name": "988 Suicide & Crisis Lifeline" + }, + "crisisTextLine": { + "name": "Crisis Text Line" + } + } + }, + "records": { + "disclaimer": "Sono i tuoi dati, non una diagnosi medica. HealthLog non diagnostica, non cura e non li interpreta.", + "allergies": { + "cardTitle": "Allergie e intolleranze", + "cardDescription": "Sostanze a cui reagisci, con la reazione e la sua gravità.", + "description": "Registra un'allergia o un'intolleranza.", + "add": "Aggiungi allergia", + "addFirst": "Aggiungi la tua prima allergia", + "addTitle": "Aggiungi allergia", + "editTitle": "Modifica allergia", + "formDescription": "La sostanza è salvata in chiaro; la reazione e la nota sono cifrate.", + "emptyTitle": "Nessuna allergia registrata", + "emptyDescription": "Aggiungi le sostanze a cui reagisci e le reazioni.", + "loadError": "Impossibile caricare le tue allergie.", + "deletedToast": "Allergia eliminata", + "deleteError": "Impossibile eliminare l'allergia.", + "deleteConfirmTitle": "Eliminare questa allergia?", + "deleteConfirmDescription": "Questo rimuove il record. Non può essere annullato.", + "category": { + "FOOD": "Alimento", + "MEDICATION": "Farmaco", + "ENVIRONMENT": "Ambiente", + "BIOLOGIC": "Biologico", + "OTHER": "Altro" + }, + "type": { + "ALLERGY": "Allergia", + "INTOLERANCE": "Intolleranza" + }, + "severity": { + "NONE": "Non valutata", + "MILD": "Lieve", + "MODERATE": "Moderata", + "SEVERE": "Grave" + }, + "status": { + "ACTIVE": "Attiva", + "INACTIVE": "Inattiva", + "RESOLVED": "Risolta" + }, + "form": { + "substance": "Sostanza", + "substancePlaceholder": "es. Penicillina, Arachidi", + "category": "Categoria", + "type": "Tipo", + "severity": "Gravità", + "status": "Stato", + "onset": "Notata per la prima volta", + "reaction": "Reazione", + "reactionPlaceholder": "es. orticaria, gonfiore, anafilassi", + "note": "Nota", + "notePlaceholder": "Qualsiasi altra cosa da ricordare", + "requiredError": "Inserisci la sostanza.", + "savedToast": "Allergia salvata", + "updatedToast": "Allergia aggiornata", + "saveError": "Impossibile salvare l'allergia." + } + }, + "family": { + "cardTitle": "Anamnesi familiare", + "cardDescription": "Patologie dei tuoi parenti, una voce per patologia.", + "description": "Registra una patologia di un parente.", + "add": "Aggiungi voce", + "addFirst": "Aggiungi la tua prima voce", + "addTitle": "Aggiungi anamnesi familiare", + "editTitle": "Modifica anamnesi familiare", + "formDescription": "La parentela e la patologia sono salvate in chiaro; la nota è cifrata.", + "emptyTitle": "Nessuna anamnesi familiare", + "emptyDescription": "Aggiungi le patologie presenti nella tua famiglia.", + "loadError": "Impossibile caricare la tua anamnesi familiare.", + "deletedToast": "Voce eliminata", + "deleteError": "Impossibile eliminare la voce.", + "deleteConfirmTitle": "Eliminare questa voce?", + "deleteConfirmDescription": "Questo rimuove il record. Non può essere annullato.", + "ageAtOnsetShort": "Età {age}", + "relationship": { + "MOTHER": "Madre", + "FATHER": "Padre", + "SISTER": "Sorella", + "BROTHER": "Fratello", + "DAUGHTER": "Figlia", + "SON": "Figlio", + "GRANDMOTHER_MATERNAL": "Nonna (materna)", + "GRANDFATHER_MATERNAL": "Nonno (materno)", + "GRANDMOTHER_PATERNAL": "Nonna (paterna)", + "GRANDFATHER_PATERNAL": "Nonno (paterno)", + "AUNT": "Zia", + "UNCLE": "Zio", + "COUSIN": "Cugino/a", + "HALF_SIBLING": "Fratellastro/sorellastra", + "OTHER": "Altro parente" + }, + "form": { + "relationship": "Parente", + "condition": "Patologia", + "conditionPlaceholder": "es. Diabete di tipo 2, Cancro al seno", + "ageAtOnset": "Età di insorgenza", + "ageAtOnsetPlaceholder": "es. 55", + "note": "Nota", + "notePlaceholder": "Qualsiasi altra cosa da ricordare", + "requiredError": "Inserisci la patologia.", + "ageError": "Inserisci un'età tra 0 e 120.", + "savedToast": "Voce salvata", + "updatedToast": "Voce aggiornata", + "saveError": "Impossibile salvare la voce." + } + } + }, + "environment": { + "fields": { + "tempMean": "Temperatura giornaliera", + "tempMin": "Minima notturna", + "sunshine": "Sole", + "daylight": "Ore di luce", + "precip": "Precipitazioni", + "pressureMean": "Pressione atmosferica", + "pressureDelta": "Variazione di pressione" + } + }, + "documents": { + "title": "Documenti in entrata", + "subtitle": "Carica un documento clinico e rivedi i dati che abbiamo letto.", + "disclaimer": { + "title": "Leggiamo, non interpretiamo", + "body": "Trascriviamo solo ciò che il documento indica — valori, codici e date — e non formuliamo diagnosi, non interpretiamo e non segnaliamo nulla. Niente viene salvato finché non lo verifichi e lo approvi. Solo il tuo medico può interpretare questi risultati." + }, + "upload": { + "label": "Carica un documento", + "kindLabel": "Tipo di documento", + "button": "Carica", + "uploading": "Lettura…", + "fileLabel": "File del documento" + }, + "kind": { + "DOCTOR_REPORT": "Referto medico", + "DISCHARGE_LETTER": "Lettera di dimissione", + "OTHER": "Altro" + }, + "list": { + "empty": "Ancora nessun documento." + }, + "status": { + "EXTRACTING": "Lettura", + "EXTRACTED": "Pronto per la revisione", + "FAILED": "Impossibile leggere", + "CONFIRMED": "Rivisto", + "DISCARDED": "Scartato" + }, + "review": { + "title": "Rivedi i dati estratti", + "discard": "Scarta", + "confirm": "Salva i dati approvati", + "confirming": "Salvataggio…", + "needsReview": "Da rivedere", + "approve": "Approva", + "reject": "Rifiuta", + "edit": "Modifica", + "cancel": "Annulla", + "save": "Salva" + }, + "empty": { + "noFacts": "Niente altro da rivedere." + }, + "factType": { + "CONDITION": "Diagnosi", + "OBSERVATION": "Valore", + "MEDICATION_STATEMENT": "Farmaco" + }, + "fields": { + "label": "Nome", + "value": "Valore", + "unit": "Unità", + "date": "Data", + "dose": "Dose" + }, + "toast": { + "uploaded": "Documento letto. Rivedi i dati qui sotto.", + "uploadFailed": "Impossibile leggere il documento.", + "confirmed": "Dati approvati salvati.", + "confirmFailed": "Impossibile salvare le tue scelte.", + "saved": "Dato aggiornato.", + "discarded": "Documento scartato." + } + }, + "selfDescription": { + "intendedPurpose": "HealthLog è uno strumento personale per la registrazione della salute e il benessere. Ti aiuta a registrare, visualizzare ed esportare i tuoi dati di salute. Non diagnostica, cura, guarisce né previene alcuna malattia e non è un dispositivo medico.", + "coach": "Il Coach lavora sui tuoi dati registrati. Non è un medico e non fornisce consigli medici: per una diagnosi o una terapia rivolgiti a un professionista sanitario.", + "insights": "Sono andamenti descrittivi calcolati dai tuoi dati registrati, non una valutazione clinica né una diagnosi.", + "labs": "Sono i tuoi referti, non una diagnosi medica. Gli intervalli di riferimento variano da laboratorio a persona: solo il tuo medico può interpretarli per te.", + "medications": "Questo registra i farmaci che annoti e la tua aderenza. Non valuta dosaggi o interazioni e non è un consiglio medico: confermali con il farmacista o il medico.", + "emergency": "Se potrebbe trattarsi di un'emergenza, contatta subito il numero di emergenza locale o un medico. HealthLog non può aiutare in caso di emergenza.", + "dataPosture": "HealthLog è self-hosted: i tuoi dati di salute restano sulla tua istanza, cifrati a riposo. Le funzioni di IA usano una chiave del provider che fornisci tu o un modello locale, quindi nulla lascia la tua istanza oltre il provider che configuri, e non c'è alcuna analisi di terze parti sui tuoi dati di salute." } } diff --git a/messages/pl.json b/messages/pl.json index 5631b6f90..6e0a5ce53 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -35,7 +35,8 @@ "showPassword": "Pokaż hasło", "hidePassword": "Ukryj hasło", "learnMore": "Dowiedz się więcej", - "dismiss": "Odrzuć" + "dismiss": "Odrzuć", + "moreInfo": "Więcej informacji" }, "doctorReport": { "title": "Raport zdrowotny", @@ -238,7 +239,9 @@ "cycle": "Cykl", "labs": "Wyniki badań", "vorsorge": "Badania", - "illness": "Choroba" + "illness": "Choroba", + "documents": "Dokumenty", + "mentalWellbeing": "Dobrostan psychiczny" }, "auth": { "login": "Zaloguj się", @@ -574,7 +577,15 @@ "bulkDeleteSuccess": "Usunięto pomiary: {count}", "bulkDeleteError": "Błąd podczas usuwania wybranych pomiarów", "bulkDeleteConfirmTitle": "Usunąć pomiary ({count})?", - "bulkDeleteConfirmBody": "Wybrane pomiary ({count}) zostaną trwale usunięte." + "bulkDeleteConfirmBody": "Wybrane pomiary ({count}) zostaną trwale usunięte.", + "typePhq9Score": "Wynik PHQ-9", + "typeGad7Score": "Wynik GAD-7", + "typeGripStrength": "Siła chwytu", + "typePainNrs": "Ból (0–10)", + "typeWaistCircumference": "Obwód talii", + "typeWaistToHeight": "Stosunek talii do wzrostu", + "unitScore": "pkt", + "unitRatio": "stosunek" }, "mood": { "title": "Nastrój", @@ -750,7 +761,8 @@ "sectionFactors": "Czynniki", "sectionNoteDetails": "Notatka i szczegóły", "quickTagsHelp": "Twoje częste tagi — jedno dotknięcie", - "quickTagsExpand": "Więcej" + "quickTagsExpand": "Więcej", + "loadError": "Nie udało się załadować wpisów nastroju." }, "medications": { "title": "Leki", @@ -1852,7 +1864,20 @@ "sat": "Sobota", "sun": "Niedziela" }, - "rangeBand": "Zakres" + "rangeBand": "Zakres", + "a11y": { + "summary": "Wykres {metric}. Zakres od {min} do {max}, ostatnio {latest}, trend {trend}.", + "noData": "Wykres {metric}. Brak dostępnych danych.", + "trendUp": "rosnący", + "trendDown": "malejący", + "trendFlat": "stały", + "scatterSummary": "Wykres korelacji, {count} punktów porównujących {xLabel} z {yLabel}.", + "scatterNoData": "Wykres korelacji {xLabel} i {yLabel}. Brak dostępnych danych.", + "moodHeatmap": "Mapa cieplna nastroju od {start} do {end}, zarejestrowano {days} dni.", + "moodHeatmapEmpty": "Mapa cieplna nastroju, brak zarejestrowanych dni.", + "complianceHeatmap": "Mapa cieplna przyjmowania leków od {start} do {end}, {days} aktywnych dni.", + "complianceHeatmapEmpty": "Mapa cieplna przyjmowania leków, brak aktywnych dni." + } }, "chart": { "overlay": { @@ -2037,7 +2062,11 @@ "noProviderTitle": "Brak połączonego dostawcy AI", "noProviderDescription": "Codzienny briefing podsumowuje Twoje pomiary prostym językiem. HealthLog potrzebuje do tego dostawcy AI — użyj własnego klucza (OpenAI, Anthropic) lub modelu lokalnego. Twoje dane pozostają u Ciebie.", "noProviderAction": "Otwórz ustawienia AI", - "staleNoProviderHint": "Tego briefingu nie można odświeżyć — nie połączono żadnego dostawcy AI." + "staleNoProviderHint": "Tego briefingu nie można odświeżyć — nie połączono żadnego dostawcy AI.", + "refreshFailedHint": "Nie udało się odświeżyć briefingu — pokazujemy poprzedni.", + "retryAction": "Spróbuj ponownie", + "failedTitle": "Nie udało się wygenerować briefingu", + "failedDescription": "Dostawca AI przekroczył limit czasu lub zwrócił błąd. Spróbuj ponownie albo zwiększ limit czasu odpowiedzi w ustawieniach AI dla wolnego modelu lokalnego." }, "trendsRow": { "title": "Trendy", @@ -2372,7 +2401,8 @@ "correlationMoodPulse": "Czy istnieje rzeczywisty związek między moim nastrojem a tętnem? Pomóż mi odczytać tę korelację.", "correlationWeightWeekday": "Czy moja waga podlega cotygodniowemu wzorcowi? Co mówi mi ta korelacja według dnia tygodnia?", "recommendation": "Powiedz mi więcej o tej rekomendacji: \"{text}\" — dlaczego mnie dotyczy i co powinienem z nią zrobić?" - } + }, + "assessmentPrompt": "Wyjaśnij mi moją ocenę {metric} — co oznacza i co powinienem z tym zrobić?" }, "relativeJustNow": "przed chwilą", "relativeMinutesAgoOne": "{count} minutę temu", @@ -2444,7 +2474,8 @@ "emptyState": { "title": "Brak tętna spoczynkowego", "description": "Tętno spoczynkowe pojawi się, gdy Apple Watch lub zgodne urządzenie Withings spędzi spokojny dzień na nadgarstku." - } + }, + "coachPrefill": "Nie zarejestrowałem jeszcze tętna spoczynkowego — dlaczego dzienna wartość spoczynkowa różni się od pojedynczego pomiaru tętna i jak ją poprawić?" }, "oxygenSaturation": { "title": "Saturacja tlenu", @@ -2670,7 +2701,8 @@ "caption": "Skumulowany niedobór z ostatnich {nights} nocy", "clearValue": "Na bieżąco", "clearCaption": "Brak skumulowanego niedoboru w ostatnich {nights} nocach", - "learning": "Wciąż analizujemy Twój sen — {counted} nocy do tej pory, jeszcze {remaining}, aby uzyskać wiarygodny bilans." + "learning": "Wciąż analizujemy Twój sen — {counted} nocy do tej pory, jeszcze {remaining}, aby uzyskać wiarygodny bilans.", + "computedInfo": "To własne oszacowanie HealthLog: ruchomy bilans potrzeby snu minus sen z ostatnich nocy, który sen nadrabiający zmniejsza. Może różnić się od natywnego wskaźnika długu snu z urządzenia, które korzysta z własnego modelu zapotrzebowania." }, "average": { "title": "Średnia na noc", @@ -3585,28 +3617,7 @@ }, "sleepQuality": { "title": "Jakość snu", - "subtitle": "Nocne wyniki z Twojego urządzenia", - "assessment": { - "metric": { - "SLEEP_SCORE": "Twój ogólny wynik snu", - "SLEEP_EFFICIENCY": "efektywność snu", - "SLEEP_PERFORMANCE": "wydajność snu", - "SLEEP_CONSISTENCY": "regularność snu" - }, - "grade": { - "excellent": "doskonała", - "good": "solidna", - "fair": "przeciętna", - "low": "raczej niska" - }, - "clause": "W ciągu ostatnich 30 nocy {metric} jest {grade} i wynosi {value}.", - "closing": { - "excellent": "Trzymaj się rutyny, która Cię tu doprowadziła.", - "good": "Bardziej stałe pory zasypiania i wstawania to zwykle następny zysk.", - "fair": "Utrzymanie stałego okna snu zwykle podnosi ten wynik najbardziej.", - "low": "Regularne wyciszenie wieczorem i stała pora pobudki to pierwsze dźwignie." - } - } + "subtitle": "Nocne wyniki z Twojego urządzenia" }, "recovery": { "title": "Regeneracja", @@ -3666,7 +3677,86 @@ }, "updatedTodayAt": "Zaktualizowano dzisiaj, {time}", "updatedYesterday": "Zaktualizowano wczoraj", - "updatedOn": "Zaktualizowano {date}" + "updatedOn": "Zaktualizowano {date}", + "healthStatus": { + "sectionTitle": "Co się zmieniło", + "subtitle": "Sygnały odbiegające od Twojego zwykłego zakresu.", + "deviationAbove": "{vital} jest powyżej Twojego zwykłego zakresu.", + "deviationBelow": "{vital} jest poniżej Twojego zwykłego zakresu.", + "shiftUp": "{vital} przesunął się w górę względem Twojej ostatniej normy.", + "shiftDown": "{vital} przesunął się w dół względem Twojej ostatniej normy.", + "rangeContext": "Teraz {value}, zwykle {low}–{high}.", + "shiftContext": "Około {before} wcześniej, {after} od tego czasu." + }, + "breathingScreening": { + "sectionTitle": "Oddychanie podczas snu", + "subtitle": "Sygnał przesiewowy z Twoich danych o śnie.", + "classificationElevated": "Twoje urządzenie wykryło podwyższone zaburzenia oddychania.", + "classificationNotElevated": "Klasyfikacja Twojego urządzenia nie jest podwyższona.", + "trendUp": "Wzrost względem wcześniejszych nocy.", + "trendDown": "Spadek względem wcześniejszych nocy.", + "trendStable": "Stabilnie względem wcześniejszych nocy.", + "nights": "Śledzono {count} nocy", + "indexLine": "ostatni średni indeks {value}", + "eventsFlagged": "{count} zdarzeń oznaczonych przez urządzenie", + "disclaimer": "To sygnał przesiewowy, a nie diagnoza — skonsultuj się z lekarzem." + }, + "labsChanges": { + "sectionTitle": "Od ostatnich badań", + "subtitle": "Jak najnowsze wyniki wypadają na tle poprzednich badań.", + "statusInRange": "w normie", + "statusBelow": "poniżej zakresu", + "statusAbove": "powyżej zakresu", + "directionFlat": "bez zmian" + }, + "navGripStrength": "Siła chwytu", + "navPainNrs": "Ból", + "navWaistCircumference": "Talia", + "navWaistToHeight": "Talia/wzrost", + "gripStrength": { + "title": "Siła chwytu", + "description": "Maksymalna siła chwytu dłoni — solidny wskaźnik ogólnej siły ciała. Poniżej progu zależnego od płci (mężczyźni 27 kg, kobiety 16 kg) sygnalizuje prawdopodobną sarkopenię; odczyt prowadzi Twój własny trend.", + "chartTitle": "Siła chwytu", + "emptyState": { + "title": "Brak pomiarów siły chwytu", + "description": "Zapisz pomiar z dynamometru ręcznego (zanotuj najlepszy z kilku ściśnięć), a trend pojawi się tutaj.", + "cta": "Zapisz siłę chwytu" + }, + "coachPrefill": "Nie zapisałem jeszcze siły chwytu — co mówi o zdrowiu moich mięśni i jak ją zmierzyć oraz poprawić?" + }, + "pain": { + "title": "Ból (0–10)", + "description": "Zgłaszana przez pacjenta intensywność bólu w skali numerycznej 0–10, gdzie 0 to brak bólu, a 10 najgorszy wyobrażalny. Utrzymująca się zmiana o 2 punkty to sygnał do obserwacji.", + "chartTitle": "Ból (0–10)", + "emptyState": { + "title": "Brak ocen bólu", + "description": "Zapisz, jak odczuwasz ból teraz, w skali 0–10, a trend pojawi się tutaj.", + "cta": "Zapisz ocenę bólu" + }, + "coachPrefill": "Nie zapisałem jeszcze ocen bólu — jak śledzić ból w skali 0–10 i co liczy się jako istotna zmiana?" + }, + "waist": { + "title": "Obwód talii", + "description": "Obwód talii w centymetrach — wskaźnik tłuszczu brzusznego. Próg zwiększonego ryzyka WHO zależy od płci (mężczyźni 94 cm, kobiety 80 cm); odczyt prowadzi Twój własny trend.", + "chartTitle": "Obwód talii", + "emptyState": { + "title": "Brak pomiarów talii", + "description": "Zmierz talię w połowie odległości między najniższym żebrem a kością biodrową, na końcu normalnego wydechu, i zapisz tutaj.", + "cta": "Zapisz obwód talii" + }, + "coachPrefill": "Nie zapisałem jeszcze pomiarów talii — jak prawidłowo zmierzyć talię i co oznacza ta liczba dla mojego zdrowia?" + }, + "waistToHeight": { + "title": "Stosunek talii do wzrostu", + "description": "Twoja talia podzielona przez wzrost — najbardziej uniwersalna pojedyncza liczba. Utrzymanie go poniżej 0,5 (talia mniejsza niż połowa wzrostu) to prosty cel.", + "chartTitle": "Stosunek talii do wzrostu", + "emptyState": { + "title": "Brak stosunku talii do wzrostu", + "description": "Zapisz stosunek talii do wzrostu, a trend pojawi się tutaj. Zasada: utrzymuj talię poniżej połowy wzrostu.", + "cta": "Zapisz stosunek talii do wzrostu" + }, + "coachPrefill": "Nie zapisałem jeszcze stosunku talii do wzrostu — dlaczego to przydatna liczba i jak utrzymać ją poniżej 0,5?" + } }, "thresholds": { "defaultLabel": "Domyślne", @@ -4012,7 +4102,8 @@ "skinTemperature": "Temperatura skóry", "respiratoryRate": "Częstość oddechów", "recovery": "Regeneracja", - "stress": "Stres" + "stress": "Stres", + "sleepDebt": "Dług snu" }, "sourceLabels": { "WITHINGS": "Withings", @@ -4235,6 +4326,50 @@ "mcp": { "title": "Łącznik MCP", "subtitle": "Połącz zewnętrznego asystenta z własnymi danymi zdrowotnymi." + }, + "anamnesis": { + "title": "Wywiad medyczny", + "subtitle": "Zarządzaj swoimi alergiami i wywiadem rodzinnym.", + "description": "Ustrukturyzowane rekordy alergii i wywiadu rodzinnego." + }, + "environment": { + "title": "Środowisko", + "subtitle": "Kontekst pogody, światła dziennego i lokalizacji.", + "disabledHint": "Włącz moduł Środowisko, aby ustawić lokalizację domową i pobierać dzienną pogodę oraz długość dnia.", + "searchPlaceholder": "Szukaj miasta", + "search": "Szukaj", + "searchError": "Nie udało się wyszukać tego miejsca.", + "use": "Użyj", + "select": "Wybierz", + "saveError": "Nie udało się zapisać zmiany.", + "homeSaved": "Zapisano lokalizację domową.", + "travelAdded": "Dodano okres lokalizacji.", + "travelRemoved": "Usunięto okres lokalizacji.", + "backfillQueued": "Zaplanowano uzupełnianie.", + "backfillError": "Nie udało się rozpocząć uzupełniania.", + "startDate": "Data początkowa", + "endDate": "Data końcowa", + "storedDays": "Zapisano dni pogody: {count}.", + "home": { + "title": "Lokalizacja domowa", + "description": "Przybliżona lokalizacja na poziomie miasta – nigdy dokładny ślad. Pogoda i światło dzienne są pobierane dla tego miejsca w dni bez okresu lokalizacji, od dnia jego ustawienia.", + "current": "Obecnie: {label}", + "none": "Nie ustawiono jeszcze lokalizacji domowej.", + "since": "Pogoda zapisywana od {date}. Wcześniejsze dni pozostają puste, dopóki nie dodasz okresu lokalizacji." + }, + "travel": { + "title": "Okresy lokalizacji", + "description": "Dodaj miejsce i zakres dat: podróż lub fragment wcześniejszej historii. Okres zastępuje Twój dom w dniach, które obejmuje, aby pogoda odpowiadała miejscu, w którym naprawdę byłeś. W ten sposób korygujesz przeszłość.", + "none": "Brak okresów lokalizacji.", + "add": "Dodaj {label}", + "remove": "Usuń okres lokalizacji" + }, + "backfill": { + "title": "Uzupełnij minione dni", + "description": "Pobierz i zapisz pogodę dla minionego zakresu dat, aby starsze dni weszły do korelacji. Początek domyślnie przyjmuje datę obowiązywania Twojego domu; aby sięgnąć dalej wstecz, najpierw dodaj okres lokalizacji dla tego zakresu.", + "run": "Uzupełnij" + }, + "noResults": "Nie znaleziono pasujących miejsc." } }, "profile": "Profil", @@ -6076,7 +6211,13 @@ "statusOff": "Wyłączone", "enabledToast": "Proaktywne podpowiedzi Coacha włączone.", "disabledToast": "Proaktywne podpowiedzi Coacha wyłączone.", - "saveError": "Nie udało się zapisać. Spróbuj ponownie później." + "saveError": "Nie udało się zapisać. Spróbuj ponownie później.", + "suggestionsLabel": "Przykładowe sugestie", + "suggestionsDesc": "Czasami pokazuje gotowe przykładowe pytanie na ekranie Coacha.", + "suggestionsAria": "Przełącz przykładowe sugestie Coacha", + "aiComposedLabel": "Pozwól Coachowi sformułować przypomnienie", + "aiComposedDesc": "Gdy dostawca jest dostępny, proaktywne przypomnienie tworzy model zamiast stałego szablonu. Domyślnie wyłączone.", + "aiComposedAria": "Przełącz proaktywne przypomnienie tworzone przez SI" }, "lowStock": { "title": "Alert niskiego zapasu", @@ -6173,7 +6314,9 @@ "resetToDefault": "Przywróć wartość domyślną", "saveSuccess": "Zakres docelowy zaktualizowany", "saveError": "Nie udało się zapisać zakresu docelowego", - "resetSuccess": "Zakres docelowy przywrócono do domyślnego" + "resetSuccess": "Zakres docelowy przywrócono do domyślnego", + "validationOrder": "Wartość minimalna musi być niższa niż maksymalna.", + "validationBounds": "Wprowadź wartości w dozwolonym zakresie podanym poniżej." }, "currentValue": "Aktualnie: {value} {unit}", "targetRangeValue": "Zakres docelowy: {min}–{max} {unit}", @@ -6305,21 +6448,18 @@ "dailyBody": "Jak się dzisiaj czujesz?" }, "coachNudges": { - "complianceTitle": "Twój Coach ma na ten temat przemyślenie", - "complianceBody": "W tym tygodniu Twoje dawki były przyjmowane nieregularnie. Porozmawiaj chwilę z Coachem — może plan da się uprościć.", - "bpTitle": "Podwyższone ciśnienie w tym tygodniu", - "bpBody": "Twoje średnie ciśnienie w tym tygodniu przekroczyło cel. Coach pomoże Ci umieścić wyniki w kontekście.", - "scoreTitle": "Twój wskaźnik regeneracji spada", - "scoreBody": "Twój wskaźnik regeneracji wyraźnie spadł w tym tygodniu. Coach pomoże Ci zawęzić możliwe przyczyny.", - "selfContextTitle": "Pomóż Coachowi lepiej Cię poznać", - "selfContextBody": "Twój opis o sobie jest niepełny lub dawno nie był aktualizowany. Dwie minuty w ustawieniach utrzymają kontekst Coacha w aktualnym stanie.", - "weightTitle": "Twoja waga oddala się od zakresu docelowego", - "weightBody": "Twoja tygodniowa średnia jeszcze bardziej oddaliła się od zakresu docelowego. Coach pomoże Ci spokojnie podsumować sytuację.", - "sleepDebtTitle": "Kilka krótkich nocy w tym tygodniu", - "sleepDebtBody": "Większość Twoich ostatnich nocy była poniżej celu snu. Coach pomoże Ci poszukać wzorców.", - "measurementGapTitle": "Twój dziennik ucichł", - "measurementGapBody": "Od tygodnia nie pojawiają się nowe wartości. Krótka rozmowa z Coachem pomoże wrócić do rutyny — albo wykryć zepsutą synchronizację.", - "focusSuffix": "Chciałeś/chciałaś zwracać uwagę na: {focus}.", + "greetingMorning": "Dzień dobry", + "greetingMorningNamed": "Dzień dobry, {name}", + "greetingHey": "Cześć", + "greetingHeyNamed": "Cześć, {name}", + "complianceBody": "W tym tygodniu Twoje przyjmowanie dawek trochę się rozjechało. Jeśli chcesz, zobaczmy razem, co by Ci to ułatwiło.", + "bpBody": "Twoje ciśnienie było w tym tygodniu nieco powyżej zwykłego zakresu. Kiedy zechcesz, chętnie przyjrzę się temu z Tobą.", + "scoreBody": "Twoja regeneracja od kilku dni nieco słabnie. Jeśli jesteś ciekaw przyczyny, jestem tu, żeby o tym porozmawiać.", + "selfContextBody": "Dawno nie aktualizowano Twojego opisu o sobie. Gdy znajdziesz chwilę, odświeżenie go pomaga mi lepiej Cię zrozumieć.", + "weightBody": "Twoja waga ostatnio nieco oddaliła się od zakresu docelowego. Jeśli chcesz, spokojnie podsumujemy to razem.", + "sleepDebtBody": "Kilka Twoich nocy było w tym tygodniu krótkich. Jeśli masz ochotę, poszukajmy razem wzorca.", + "measurementGapBody": "Twój dziennik milczy od około tygodnia. Kiedy Ci pasuje, krótka rozmowa pomoże wrócić do rutyny.", + "focusNote": "Wiąże się to też z tym, na co chciałeś/chciałaś zwracać uwagę.", "questionConditions": "Czy masz choroby przewlekłe, o których Coach powinien wiedzieć?", "questionFocus": "Czy jest coś konkretnego, na co Coach powinien zwracać uwagę?", "questionAllergies": "Czy masz alergie lub nietolerancje?", @@ -6358,6 +6498,11 @@ "label": "Hasło", "error": "To hasło jest nieprawidłowe.", "submit": "Odblokuj" + }, + "loadError": { + "title": "Nie udało się załadować udostępnionego widoku", + "description": "Podczas otwierania udostępnionej dokumentacji zdrowotnej wystąpił błąd. Link mógł wygasnąć lub wystąpił chwilowy problem. Spróbuj ponownie albo poproś osobę, która go udostępniła, o nowy link.", + "retry": "Spróbuj ponownie" } }, "cycle": { @@ -6831,7 +6976,8 @@ "empty": "Brak nadchodzących przypomnień o badaniach.", "openLink": "Otwórz badania" }, - "showMeasurements": "Pokaż pomiary" + "showMeasurements": "Pokaż pomiary", + "loadError": "Nie udało się załadować przypomnień." }, "moduleList": { "viewToggleLabel": "Widok", @@ -6942,7 +7088,7 @@ "deletedToast": "Biomarker usunięty", "deleteError": "Nie udało się usunąć biomarkera", "deleteConfirmTitle": "Usunąć biomarker?", - "deleteConfirmDescription": "Definicja zostanie usunięta. Istniejące pomiary pozostaną, ale bez powiązania.", + "deleteConfirmDescription": "Biomarker i wszystkie jego zapisane wartości zostaną trwale usunięte. Tej operacji nie można cofnąć.", "emptyTitle": "Brak biomarkerów", "emptyDescription": "Zdefiniuj markery, takie jak LDL lub HbA1c, aby zapisywać wartości.", "loadError": "Nie udało się wczytać biomarkerów", @@ -6977,7 +7123,14 @@ "unhiddenToast": "Biomarker przywrócony", "hideError": "Nie udało się zaktualizować biomarkera", "allHidden": "Wszystkie biomarkery są ukryte.", - "hiddenSection": "Ukryte" + "hiddenSection": "Ukryte", + "targetRange": { + "title": "Dostosuj zakres docelowy", + "description": "Ustaw dolną i górną wartość referencyjną dla tego markera. Pozostaw pole puste, aby granica była otwarta.", + "save": "Zapisz zakres", + "savedToast": "Zaktualizowano zakres docelowy", + "saveError": "Nie udało się zaktualizować zakresu docelowego" + } }, "catalog": { "total-cholesterol": "Cholesterol całkowity", @@ -7044,8 +7197,16 @@ "hemoglobin": "Białko czerwonych krwinek transportujące tlen.", "hematocrit": "Udział czerwonych krwinek w objętości krwi.", "wbc": "Całkowita liczba białych krwinek, krążącej obrony układu odpornościowego.", - "platelets": "Fragmenty komórkowe, które zlepiają się, tworząc skrzepy krwi." - } + "platelets": "Fragmenty komórkowe, które zlepiają się, tworząc skrzepy krwi.", + "apob": "Bezpośrednio liczy cząstki aterogenne; często lepszy marker ryzyka niż LDL-C, gdy się różnią.", + "lp-a": "W dużej mierze dziedziczny, dożywotni czynnik ryzyka — zwykle mierzony raz. Podawaj w jednostkach molowych (nmol/L).", + "fasting-insulin": "Łączy się z glukozą na czczo do oszacowania insulinooporności (HOMA-IR).", + "omega-3-index": "Udział EPA+DHA w czerwonych krwinkach; 8–12% to powszechnie podawany zakres kardioprotekcyjny." + }, + "apob": "Apolipoproteina B (ApoB)", + "lp-a": "Lipoproteina(a)", + "fasting-insulin": "Insulina na czczo", + "omega-3-index": "Indeks omega-3" }, "noteLoadFailed": "Nie udało się wczytać notatki; pozostanie niezmieniona po zapisaniu.", "showingLatestOf": "Wyświetlanie najnowszych {shown} z {total} wyników", @@ -7092,7 +7253,8 @@ "fileType": "Prześlij plik JPEG, PNG, WebP lub PDF.", "extractFailed": "Nie udało się odczytać wyniku. Spróbuj wyraźniejszego zdjęcia.", "pdfNeedsAnthropic": "Skanowanie PDF wymaga dostawcy wizji Claude; prześlij zamiast tego zdjęcie." - } + }, + "loadMoreReadings": "Wczytaj więcej pomiarów" }, "modules": { "cycle": { @@ -7150,6 +7312,18 @@ "mcp": { "label": "Dostęp asystenta zewnętrznego (MCP)", "description": "Pozwól asystentowi obsługującemu MCP czytać Twoje dane zdrowotne za pomocą tokena o ograniczonym zakresie. Domyślnie wyłączone." + }, + "environment": { + "label": "Środowisko", + "description": "Pobieraj dzienną pogodę i długość dnia dla swojej lokalizacji, aby powiązać je z nastrojem, snem i parametrami życiowymi. Domyślnie wyłączone." + }, + "inboundDocuments": { + "label": "Dokumenty przychodzące", + "description": "Prześlij raport lekarski lub wypis; sprawdź wyodrębnione dane przed zapisaniem. Domyślnie wyłączone." + }, + "mentalHealth": { + "label": "Dobrostan psychiczny", + "description": "Dobrowolne testy PHQ-9 i GAD-7 obok dziennika nastroju. Domyślnie wyłączone; odpowiedzi są szyfrowane i pozostają poza Coachem AI." } }, "illness": { @@ -7202,8 +7376,8 @@ "note": "Notatka", "notePlaceholder": "Cokolwiek wartego zapamiętania (zaszyfrowane).", "saveError": "Nie udało się zapisać epizodu. Spróbuj ponownie.", - "parent": "Part of", - "parentNone": "Not linked" + "parent": "Część", + "parentNone": "Niepowiązane" }, "sheet": { "title": "Zapisz {date}", @@ -7284,35 +7458,36 @@ "fever": "Temperatura: {value} °C", "empty": "Nie zapisano jeszcze żadnych dni." }, - "loadError": "Could not load this episode.", - "backToList": "Back to journal", + "loadError": "Nie udało się wczytać tego epizodu.", + "backToList": "Powrót do dziennika", "detail": { "note": "Note" }, "edit": { - "title": "Edit episode", - "description": "Update this episode's details." + "title": "Edytuj epizod", + "description": "Zaktualizuj szczegóły tego epizodu." }, "menu": { - "label": "Episode actions" + "label": "Działania epizodu" }, "deleteConfirm": { - "title": "Delete this episode?", - "body": "“{label}” and all its day-logs will be removed. This cannot be undone." + "title": "Usunąć ten epizod?", + "body": "„{label}” oraz wszystkie powiązane wpisy dzienne zostaną usunięte. Tej operacji nie można cofnąć." }, "vital": { - "RESTING_HEART_RATE": "Resting heart rate", - "HEART_RATE_VARIABILITY": "Heart-rate variability", + "RESTING_HEART_RATE": "Spoczynkowe tętno", + "HEART_RATE_VARIABILITY": "Zmienność rytmu serca", "RECOVERY_SCORE": "Recovery", - "OXYGEN_SATURATION": "Oxygen saturation", - "BODY_TEMPERATURE": "Body temperature", - "SKIN_TEMPERATURE": "Skin temperature", - "RESPIRATORY_RATE": "Respiratory rate", - "BLOOD_PRESSURE_SYS": "Systolic blood pressure", - "BLOOD_PRESSURE_DIA": "Diastolic blood pressure", + "OXYGEN_SATURATION": "Saturacja tlenem", + "BODY_TEMPERATURE": "Temperatura ciała", + "SKIN_TEMPERATURE": "Temperatura skóry", + "RESPIRATORY_RATE": "Częstość oddechów", + "BLOOD_PRESSURE_SYS": "Ciśnienie skurczowe", + "BLOOD_PRESSURE_DIA": "Ciśnienie rozkurczowe", "PULSE": "Pulse", "WEIGHT": "Weight" - } + }, + "listLoadError": "Nie udało się załadować epizodów." }, "coach": { "reminderSuggestion": { @@ -7372,5 +7547,318 @@ "glucoseHighDoctor": "Dwa ostatnie pomiary wyniosły {value} mg/dL lub więcej. Zmierz ponownie, nawodnij się i jak najszybciej skontaktuj się z lekarzem, jeśli pozostaje tak wysoki.", "glucoseHighEmergency": "Dwa ostatnie pomiary wyniosły {value} mg/dL lub więcej i zgłosiłeś objawy. W razie wymiotów, przyspieszonego oddechu lub dezorientacji natychmiast zadzwoń po pomoc." } + }, + "version": { + "updateAvailable": { + "title": "Dostępna jest nowa wersja", + "reload": "Załaduj ponownie", + "dismiss": "Odrzuć" + } + }, + "mentalHealth": { + "pageTitle": "Sprawdzenie dobrostanu psychicznego", + "pageDescription": "Dwa zwalidowane, dobrowolne testy — PHQ-9 (nastrój) i GAD-7 (lęk) — obok Twojego dziennika nastroju. Liczy się trend w czasie, a nie pojedynczy wynik.", + "disclaimer": "To przesiewowa samoocena, a nie diagnoza. Wysoki lub niski wynik nie oznacza, że masz lub nie masz danego schorzenia. Tylko wykwalifikowany pracownik ochrony zdrowia może postawić diagnozę. W razie wątpliwości porozmawiaj z lekarzem.", + "optInNote": "Dobrowolne i prywatne: Twoje poszczególne odpowiedzi są szyfrowane, pozostają poza Coachem AI i eksportami i nigdy nie są udostępniane.", + "attributionLabel": "Źródło", + "choosePrompt": "Wybierz sprawdzenie", + "start": "Rozpocznij", + "back": "Wstecz", + "stem": "Jak często w ciągu ostatnich 2 tygodni dokuczały Ci następujące problemy?", + "submit": "Zobacz mój wynik", + "submitting": "Zapisywanie…", + "saved": "Zapisano", + "error": "Coś poszło nie tak. Spróbuj ponownie.", + "considerProfessional": "Twój wynik mieści się w zakresie, w którym rozmowa ze specjalistą może pomóc. To życzliwa sugestia, a nie diagnoza.", + "instrument": { + "phq9": "PHQ-9 — nastrój", + "gad7": "GAD-7 — lęk" + }, + "instrumentSub": { + "phq9": "Przesiew depresji, 9 pytań", + "gad7": "Przesiew lęku, 7 pytań" + }, + "options": { + "0": "Wcale", + "1": "Kilka dni", + "2": "Więcej niż połowę dni", + "3": "Niemal codziennie" + }, + "functionalTitle": "Jeśli zaznaczyłeś(-aś) jakiekolwiek problemy, na ile utrudniły Ci one pracę, prowadzenie domu lub kontakty z innymi ludźmi?", + "functional": { + "0": "Wcale nie utrudniły", + "1": "Trochę utrudniły", + "2": "Bardzo utrudniły", + "3": "Skrajnie utrudniły" + }, + "result": { + "title": "Twój wynik", + "totalLabel": "Wynik łączny", + "bandLabel": "Zakres dla tego przesiewu", + "takeAnother": "Wykonaj kolejne sprawdzenie" + }, + "band": { + "PHQ9": { + "minimal": "Brak / minimalny", + "mild": "Łagodny", + "moderate": "Umiarkowany", + "modSevere": "Umiarkowanie ciężki", + "severe": "Ciężki" + }, + "GAD7": { + "minimal": "Brak / minimalny", + "mild": "Łagodny", + "moderate": "Umiarkowany", + "severe": "Ciężki" + } + }, + "history": { + "title": "Twoja historia", + "empty": "Brak sprawdzeń. Twój trend pojawi się tutaj po ukończeniu pierwszego." + }, + "crisis": { + "title": "Nie musisz mierzyć się z tym sam(a)", + "intro": "Wskazałeś(-aś) myśli, że lepiej byłoby nie żyć lub zrobić sobie krzywdę. Wsparcie jest dostępne już teraz, a sięgnięcie po pomoc to oznaka siły.", + "ifDanger": "Jeśli grozi Ci bezpośrednie niebezpieczeństwo, zadzwoń teraz pod {emergency}.", + "resourcesTitle": "Osoby, które mogą pomóc" + }, + "items": { + "phq9": { + "1": "Niewielkie zainteresowanie lub odczuwanie przyjemności z wykonywania czynności", + "2": "Uczucie przygnębienia, depresji lub beznadziejności", + "3": "Kłopoty z zaśnięciem lub utrzymaniem snu albo zbyt długi sen", + "4": "Uczucie zmęczenia lub brak energii", + "5": "Brak apetytu lub przejadanie się", + "6": "Złe myślenie o sobie — poczucie, że jest się nieudacznikiem lub że zawiodło się siebie albo rodzinę", + "7": "Trudności ze skupieniem się, na przykład podczas czytania gazety lub oglądania telewizji", + "8": "Poruszanie się lub mówienie tak wolno, że inni mogli to zauważyć? Albo przeciwnie — niepokój lub pobudzenie powodujące, że poruszasz się znacznie więcej niż zwykle", + "9": "Myśli, że lepiej byłoby umrzeć albo chęć zrobienia sobie w jakiś sposób krzywdy" + }, + "gad7": { + "1": "Odczuwanie zdenerwowania, lęku lub napięcia", + "2": "Niemożność przerwania lub kontrolowania zamartwiania się", + "3": "Zbytnie zamartwianie się różnymi sprawami", + "4": "Trudności z odprężeniem się", + "5": "Niepokój tak duży, że trudno usiedzieć w miejscu", + "6": "Łatwe wpadanie w rozdrażnienie lub irytację", + "7": "Odczuwanie lęku, jakby miało się zdarzyć coś strasznego" + } + }, + "crisisResource": { + "telefonSeelsorge": { + "name": "TelefonSeelsorge (całodobowo, bezpłatnie i poufnie)" + }, + "nummerGegenKummer": { + "name": "Nummer gegen Kummer (dla młodych ludzi)" + }, + "krisenchat": { + "name": "krisenchat (czat całodobowy, dla młodych ludzi)" + }, + "findahelpline": { + "name": "Find a Helpline (światowy katalog)" + }, + "euEmotionalSupport": { + "name": "Telefon wsparcia emocjonalnego" + }, + "lifeline988": { + "name": "988 Suicide & Crisis Lifeline" + }, + "crisisTextLine": { + "name": "Crisis Text Line" + } + } + }, + "records": { + "disclaimer": "To Twoje własne wpisy, a nie diagnoza lekarska. HealthLog nie diagnozuje, nie leczy i ich nie interpretuje.", + "allergies": { + "cardTitle": "Alergie i nietolerancje", + "cardDescription": "Substancje, na które reagujesz, wraz z reakcją i jej nasileniem.", + "description": "Zapisz alergię lub nietolerancję.", + "add": "Dodaj alergię", + "addFirst": "Dodaj swoją pierwszą alergię", + "addTitle": "Dodaj alergię", + "editTitle": "Edytuj alergię", + "formDescription": "Substancja jest przechowywana jako tekst; reakcja i notatka są szyfrowane.", + "emptyTitle": "Brak zapisanych alergii", + "emptyDescription": "Dodaj substancje, na które reagujesz, i ich reakcje.", + "loadError": "Nie udało się wczytać Twoich alergii.", + "deletedToast": "Alergia usunięta", + "deleteError": "Nie udało się usunąć alergii.", + "deleteConfirmTitle": "Usunąć tę alergię?", + "deleteConfirmDescription": "To usuwa wpis. Nie można tego cofnąć.", + "category": { + "FOOD": "Pokarm", + "MEDICATION": "Lek", + "ENVIRONMENT": "Środowisko", + "BIOLOGIC": "Biologiczny", + "OTHER": "Inne" + }, + "type": { + "ALLERGY": "Alergia", + "INTOLERANCE": "Nietolerancja" + }, + "severity": { + "NONE": "Nieoceniona", + "MILD": "Łagodna", + "MODERATE": "Umiarkowana", + "SEVERE": "Ciężka" + }, + "status": { + "ACTIVE": "Aktywna", + "INACTIVE": "Nieaktywna", + "RESOLVED": "Ustąpiła" + }, + "form": { + "substance": "Substancja", + "substancePlaceholder": "np. Penicylina, Orzeszki ziemne", + "category": "Kategoria", + "type": "Typ", + "severity": "Nasilenie", + "status": "Status", + "onset": "Pierwszy raz zauważono", + "reaction": "Reakcja", + "reactionPlaceholder": "np. pokrzywka, obrzęk, anafilaksja", + "note": "Notatka", + "notePlaceholder": "Cokolwiek innego, co warto zapamiętać", + "requiredError": "Podaj substancję.", + "savedToast": "Alergia zapisana", + "updatedToast": "Alergia zaktualizowana", + "saveError": "Nie udało się zapisać alergii." + } + }, + "family": { + "cardTitle": "Wywiad rodzinny", + "cardDescription": "Choroby u Twoich krewnych, jeden wpis na chorobę.", + "description": "Zapisz chorobę u krewnego.", + "add": "Dodaj wpis", + "addFirst": "Dodaj swój pierwszy wpis", + "addTitle": "Dodaj wywiad rodzinny", + "editTitle": "Edytuj wywiad rodzinny", + "formDescription": "Pokrewieństwo i choroba są przechowywane jako tekst; notatka jest szyfrowana.", + "emptyTitle": "Brak wywiadu rodzinnego", + "emptyDescription": "Dodaj choroby występujące w Twojej rodzinie.", + "loadError": "Nie udało się wczytać Twojego wywiadu rodzinnego.", + "deletedToast": "Wpis usunięty", + "deleteError": "Nie udało się usunąć wpisu.", + "deleteConfirmTitle": "Usunąć ten wpis?", + "deleteConfirmDescription": "To usuwa wpis. Nie można tego cofnąć.", + "ageAtOnsetShort": "Wiek {age}", + "relationship": { + "MOTHER": "Matka", + "FATHER": "Ojciec", + "SISTER": "Siostra", + "BROTHER": "Brat", + "DAUGHTER": "Córka", + "SON": "Syn", + "GRANDMOTHER_MATERNAL": "Babcia (ze strony matki)", + "GRANDFATHER_MATERNAL": "Dziadek (ze strony matki)", + "GRANDMOTHER_PATERNAL": "Babcia (ze strony ojca)", + "GRANDFATHER_PATERNAL": "Dziadek (ze strony ojca)", + "AUNT": "Ciotka", + "UNCLE": "Wujek", + "COUSIN": "Kuzyn/kuzynka", + "HALF_SIBLING": "Przyrodnie rodzeństwo", + "OTHER": "Inny krewny" + }, + "form": { + "relationship": "Krewny", + "condition": "Choroba", + "conditionPlaceholder": "np. Cukrzyca typu 2, Rak piersi", + "ageAtOnset": "Wiek zachorowania", + "ageAtOnsetPlaceholder": "np. 55", + "note": "Notatka", + "notePlaceholder": "Cokolwiek innego, co warto zapamiętać", + "requiredError": "Podaj chorobę.", + "ageError": "Podaj wiek od 0 do 120.", + "savedToast": "Wpis zapisany", + "updatedToast": "Wpis zaktualizowany", + "saveError": "Nie udało się zapisać wpisu." + } + } + }, + "environment": { + "fields": { + "tempMean": "Temperatura dzienna", + "tempMin": "Minimum nocne", + "sunshine": "Nasłonecznienie", + "daylight": "Światło dzienne", + "precip": "Opady", + "pressureMean": "Ciśnienie atmosferyczne", + "pressureDelta": "Wahanie ciśnienia" + } + }, + "documents": { + "title": "Dokumenty przychodzące", + "subtitle": "Prześlij dokument kliniczny i sprawdź dane, które z niego odczytaliśmy.", + "disclaimer": { + "title": "Odczytujemy, nie interpretujemy", + "body": "Przepisujemy tylko to, co podaje dokument — wartości, kody i daty — i nigdy nie stawiamy diagnozy, nie interpretujemy ani niczego nie oznaczamy. Nic nie zostaje zapisane, dopóki tego nie sprawdzisz i nie zatwierdzisz. Tylko lekarz może zinterpretować te wyniki." + }, + "upload": { + "label": "Prześlij dokument", + "kindLabel": "Typ dokumentu", + "button": "Prześlij", + "uploading": "Odczytywanie…", + "fileLabel": "Plik dokumentu" + }, + "kind": { + "DOCTOR_REPORT": "Raport lekarski", + "DISCHARGE_LETTER": "Wypis", + "OTHER": "Inny" + }, + "list": { + "empty": "Brak dokumentów." + }, + "status": { + "EXTRACTING": "Odczytywanie", + "EXTRACTED": "Gotowe do przeglądu", + "FAILED": "Nie udało się odczytać", + "CONFIRMED": "Sprawdzone", + "DISCARDED": "Odrzucone" + }, + "review": { + "title": "Przejrzyj wyodrębnione dane", + "discard": "Odrzuć", + "confirm": "Zapisz zatwierdzone dane", + "confirming": "Zapisywanie…", + "needsReview": "Wymaga przeglądu", + "approve": "Zatwierdź", + "reject": "Odrzuć", + "edit": "Edytuj", + "cancel": "Anuluj", + "save": "Zapisz" + }, + "empty": { + "noFacts": "Nic więcej do przejrzenia." + }, + "factType": { + "CONDITION": "Diagnoza", + "OBSERVATION": "Wartość", + "MEDICATION_STATEMENT": "Lek" + }, + "fields": { + "label": "Nazwa", + "value": "Wartość", + "unit": "Jednostka", + "date": "Data", + "dose": "Dawka" + }, + "toast": { + "uploaded": "Dokument odczytany. Sprawdź dane poniżej.", + "uploadFailed": "Nie udało się odczytać dokumentu.", + "confirmed": "Zatwierdzone dane zapisane.", + "confirmFailed": "Nie udało się zapisać Twoich decyzji.", + "saved": "Dane zaktualizowane.", + "discarded": "Dokument odrzucony." + } + }, + "selfDescription": { + "intendedPurpose": "HealthLog to osobiste narzędzie do rejestrowania zdrowia i dbania o dobre samopoczucie. Pomaga zapisywać, wizualizować i eksportować własne dane zdrowotne. Nie diagnozuje, nie leczy ani nie zapobiega żadnej chorobie i nie jest wyrobem medycznym.", + "coach": "Coach korzysta z Twoich własnych zapisanych danych. Nie jest lekarzem i nie udziela porad medycznych — po diagnozę lub leczenie zwróć się do pracownika ochrony zdrowia.", + "insights": "To opisowe wzorce wyliczone z Twoich własnych zapisanych danych, a nie ocena kliniczna ani diagnoza.", + "labs": "To Twoje własne zapisy, a nie diagnoza medyczna. Zakresy referencyjne różnią się w zależności od laboratorium i osoby — tylko Twój lekarz może je dla Ciebie zinterpretować.", + "medications": "To rejestruje leki, które zapisujesz, oraz Twoje stosowanie się do zaleceń. Nie ocenia dawkowania ani interakcji i nie jest poradą medyczną — potwierdź to u farmaceuty lub lekarza.", + "emergency": "Jeśli to może być nagły przypadek, zadzwoń teraz pod lokalny numer alarmowy lub skontaktuj się z lekarzem. HealthLog nie pomoże w nagłym przypadku.", + "dataPosture": "HealthLog jest hostowany samodzielnie: Twoje dane zdrowotne pozostają na Twojej instancji, zaszyfrowane w spoczynku. Funkcje AI korzystają z dostarczonego przez Ciebie klucza dostawcy lub modelu lokalnego, więc nic nie opuszcza Twojej instancji poza skonfigurowanym dostawcą i nie ma analityki firm trzecich na Twoich danych zdrowotnych." } } diff --git a/package.json b/package.json index 97dc99af7..416f768d9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.24.0", + "version": "1.25.0", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", @@ -51,7 +51,7 @@ "mcp:stdio": "npx tsx src/cli/mcp-stdio.ts" }, "dependencies": { - "@aws-sdk/client-s3": "^3.1048.0", + "@aws-sdk/client-s3": "^3.1075.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", @@ -99,8 +99,8 @@ "zxcvbn-typescript": "^5.0.1" }, "devDependencies": { - "@axe-core/playwright": "^4.11.3", - "@next/bundle-analyzer": "^16.2.6", + "@axe-core/playwright": "^4.12.1", + "@next/bundle-analyzer": "^16.2.9", "@playwright/test": "^1.60.0", "@tailwindcss/postcss": "^4", "@testcontainers/postgresql": "^12.0.3", @@ -119,11 +119,11 @@ "lefthook": "^2.1.9", "pdf-parse": "^2.4.5", "pg": "^8.22.0", - "prettier": "^3.8.4", + "prettier": "^3.9.1", "prettier-plugin-tailwindcss": "^0.8.0", "shadcn": "^4.9.0", "tailwindcss": "^4", - "testcontainers": "^12.0.1", + "testcontainers": "^12.0.3", "tw-animate-css": "^1.4.0", "typescript": "^6", "vitest": "^4.1.5" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed274e89c..727372991 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,8 +13,8 @@ importers: .: dependencies: '@aws-sdk/client-s3': - specifier: ^3.1048.0 - version: 3.1048.0 + specifier: ^3.1075.0 + version: 3.1075.0 '@dnd-kit/core': specifier: ^6.3.1 version: 6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -152,11 +152,11 @@ importers: version: 5.0.1 devDependencies: '@axe-core/playwright': - specifier: ^4.11.3 - version: 4.11.3(playwright-core@1.60.0) + specifier: ^4.12.1 + version: 4.12.1(playwright-core@1.60.0) '@next/bundle-analyzer': - specifier: ^16.2.6 - version: 16.2.6 + specifier: ^16.2.9 + version: 16.2.9 '@playwright/test': specifier: ^1.60.0 version: 1.60.0 @@ -212,11 +212,11 @@ importers: specifier: ^8.22.0 version: 8.22.0 prettier: - specifier: ^3.8.4 - version: 3.8.4 + specifier: ^3.9.1 + version: 3.9.1 prettier-plugin-tailwindcss: specifier: ^0.8.0 - version: 0.8.0(prettier@3.8.4) + version: 0.8.0(prettier@3.9.1) shadcn: specifier: ^4.9.0 version: 4.9.0(@types/node@25.9.2)(typescript@6.0.3) @@ -224,8 +224,8 @@ importers: specifier: ^4 version: 4.3.1 testcontainers: - specifier: ^12.0.1 - version: 12.0.1 + specifier: ^12.0.3 + version: 12.0.3 tw-animate-css: specifier: ^1.4.0 version: 1.4.0 @@ -265,105 +265,88 @@ packages: '@aws-crypto/util@5.2.0': resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} - '@aws-sdk/client-s3@3.1048.0': - resolution: {integrity: sha512-SrJn5FteqqtcDBgQIvqLKk3Qn/2vSsi5XR03I53EDDR4CbCdLysVSNgUnjVncEECMua9Pz+nxO0/lEx3TP+6mA==} + '@aws-sdk/checksums@3.1000.8': + resolution: {integrity: sha512-v0U9S7gBIme3OTgt1LdbAF4RpvavCc+4GK1+1xqAcqtbrHsEhjQo6R45LKcjhs/+WrRJij1Y0Gztw7QPAIeUfA==} engines: {node: '>=20.0.0'} - '@aws-sdk/core@3.974.11': - resolution: {integrity: sha512-QpnINq5FZH6EOaDEkmHdT7eUunbvD27pDNQypaWjFyYz7Zl1q3UCMQErBZxpmfGfI7MvI2TlK8KTkgNpv8b1ug==} - engines: {node: '>=20.0.0'} - deprecated: Deprecated due to an error deserialization bug in JSON 1.0 protocol services, see https://github.com/aws/aws-sdk-js-v3/pull/8031. Newer version available. - - '@aws-sdk/crc64-nvme@3.972.8': - resolution: {integrity: sha512-fVfUCL/Xh2zINYMPZvj+iBn6XWouQf0DAnjaWCI9MkmqXzL2Iy5FoQB8O7syFe6gN6AH1ecDDU58T51Ou0kFkA==} + '@aws-sdk/client-s3@3.1075.0': + resolution: {integrity: sha512-h1A6nIl1YX6Y45enGsTK7ef3ZrOnBiQJ1qF5R2K/nMWfsu6A9mc2Y5T66nxerABzyjjyyvign3MrzafnFoQKmA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-env@3.972.37': - resolution: {integrity: sha512-/jpPvEh6f7ntmIzf7dNxoNX6Q8vt8UpesCjbW6mFfk4V1NW6bIy9qxcQ6WbA8As5yQhsZOe+xeNd4xHX8kdY2Q==} + '@aws-sdk/core@3.974.23': + resolution: {integrity: sha512-MiWR/uWjxjFXGzrE0Ghc5lWxUxzHsUWFhV+OX7M4cR9SrmrnZs6TXavnCWnzzdwJeFri34xQo81rvGNzK3c4BQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-http@3.972.39': - resolution: {integrity: sha512-pIgTpisWyWg7X1bUbzSjuUYosYTD0Ghz2M0hkSTmb3a6i3qV3uU+NYJPI/E2XSC0HcsZh5rsLPzeXrkb2DS0Cg==} + '@aws-sdk/credential-provider-env@3.972.49': + resolution: {integrity: sha512-liB3yQNHCM9k/gu/w36XHMKPluT7HTlnGUhRbBGSISDQkcr/Sy1zsZabiuvQj8WG5yW573u9RehrBvvnIQ9OEQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-ini@3.972.41': - resolution: {integrity: sha512-u2tyjaxJJzW8UtW4SM1ZcPMDwO6y+kV+llvou+Adts0FAKyzes5jG4izQN+KX3yE8ZROpS5y1LJ//xL2iSf76w==} + '@aws-sdk/credential-provider-http@3.972.51': + resolution: {integrity: sha512-XET0H2oofciJ5lMRWNIvRjAP7Q3wv2XT+JtJJEdhPWUMwe3TvQ9qcxonpu7vXmNngncvFpi4E2It+Tamas/naA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-login@3.972.41': - resolution: {integrity: sha512-0LBitxXiAiaE5nlFPfpNIww/8FRY/I7WIndWsc9GmNFOM7cE1wNpVNQEGEk9Outg5l8xl+3vybxFyUy4l9q/LQ==} + '@aws-sdk/credential-provider-ini@3.972.56': + resolution: {integrity: sha512-IAmc61hbgQiHht9U3x0tnRwz0lzdwOwD/i9voRgdJrKamF+JtmrBOsW9GwB7mfFonNWOWL4qARWYrF8veEMe3w==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-node@3.972.42': - resolution: {integrity: sha512-D4oon2zbqqsWOJUM99Gm3/ZyJ0IJvTXVN3PyloGb3kQEyI36fjCZheZj422lAgTWWd6TSHgiImLt3RIaLdv3dQ==} + '@aws-sdk/credential-provider-login@3.972.55': + resolution: {integrity: sha512-hBBkANo3cDn+h2qxxzER4a+J8JCO9o9Z/YYmU7iky6AcaarX5RRdRcHNC6SLdwY0vAXQygn6soUbDqPn3GghaA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-process@3.972.37': - resolution: {integrity: sha512-7nVaHBUaWIddASYfVaA9O4D5ZVjewU3sCol9WqZPGfW0nR+0WqE0xHZnD/U2L33PlOB8KNXGKZ6wOES/QijKzg==} + '@aws-sdk/credential-provider-node@3.972.58': + resolution: {integrity: sha512-OyCLVmSI7pZO8hxwNVX6pXhTVlJqRBTp+ijdEfJSUj0RyjHnF602OfAarOzGq6wkGodeFkYBt8MmJ6A6ycRgWw==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-sso@3.972.41': - resolution: {integrity: sha512-IOWAWEHe5LkjSKkkUUX9ciV6Y1scHTsnfEkdt5yyC4Slrc7AGbkLPrpntjqh18ksJAMOaVhoBsO8p2WyTcY2wQ==} + '@aws-sdk/credential-provider-process@3.972.49': + resolution: {integrity: sha512-C8h36lBuC/RnBSsjlO+dn6xZm3KbAl5vpJaVPAfQnMmz2/OISmKOc8XZcqMQgO2ADwBYNRMM6Kf3vz9G/TulMQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-web-identity@3.972.41': - resolution: {integrity: sha512-mbACk9Yypa8nm4iGZLs0PofOXEcTDOUw6wDnsPXNDNSd2WNXs1tSo+6nc/fh0jLYdfVZThhBL98PHW4aXFsG5A==} + '@aws-sdk/credential-provider-sso@3.972.55': + resolution: {integrity: sha512-1FkOz74Ea5QGS9jtIoXp55T/IkSS3spv+nLTT07fRY/+T5xmEOqaYBVIaEmX4zTNvbV6g2lrtlaVKWEoNyJt3w==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-bucket-endpoint@3.972.13': - resolution: {integrity: sha512-JDaukix+kt5KwF7FzNSkfZHpqiPJajVkKJLJexF6z5B44+CN70BXGiQaCEAiCtKtRZNvC16eF3SY9L0bDJPlbA==} + '@aws-sdk/credential-provider-web-identity@3.972.55': + resolution: {integrity: sha512-g2BoECD1q01kTPByi56+VLVvdWDzMkKIcr77qixpqH0okw2t0U5CoPv+6S8v/D1Y2Wa6QKKtn6XAtDzP+Kfpvg==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-expect-continue@3.972.12': - resolution: {integrity: sha512-dA5pKTom/Ls9mgeyeaRBNQrRIVOLVjv4AmKOB0/e4yaiXEUy0gSz2d3liP8JHtYoCAEWySU1jWnyzwLOREN+4g==} + '@aws-sdk/middleware-flexible-checksums@3.974.33': + resolution: {integrity: sha512-qMgQSPemQq2/eW/e/0+SpY4kYR5L7dUgBiVdEc5bd+ztHNv07ZMYiI+sTiir3TgKndFfglSw/VFi7oZJ6bZ63g==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-flexible-checksums@3.974.19': - resolution: {integrity: sha512-GLciZVIvWM3C+ffuqnUqlAZwRjQdLt+KXiqr9+aRwZyKVyF2J5lrJAzzSqwweNl9hUWBN00BhilWXdMI5DjNcw==} + '@aws-sdk/middleware-sdk-s3@3.972.54': + resolution: {integrity: sha512-GDfDQ0gwLFRKN9gWIKcmVrHJ3e7XagnY7N1LLzMVNgnOnuY7f/ALgmy3CuBjosWD95T/Z6e+gs1IeWmLPkyLKQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-location-constraint@3.972.10': - resolution: {integrity: sha512-rI3NZvJcEvjoD0+0PI0iUAwlPw2IlSlhyvgBK/3WkKJQE/YiKFedd9dMN2lVacdNxPNhxL/jzQaKQdrGtQagjQ==} + '@aws-sdk/nested-clients@3.997.23': + resolution: {integrity: sha512-gO93ZPsI2bxeFZD42f1/qjDw6FAZkNZcKRO94LIiT03fzOmcJ9e/tunxjVjA1Rl69ClmVJzz8H3G9CdKef10PA==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-sdk-s3@3.972.40': - resolution: {integrity: sha512-vyFY4EsAGySqqd87Z7n4qcCYXJO3QArB8VIJzuupY5XuLHIp579HTZldIUGGABvAOzLptfPb9+lJBJcB+3/cvA==} + '@aws-sdk/signature-v4-multi-region@3.996.35': + resolution: {integrity: sha512-6L/VWs+Wch2stHemCGTmUNqKLMzURxQDK5boNG3Jn3kAOp71meDUuS5sbObpEvFxHDq0uWeSLFDNSYsjNt+Dlg==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-ssec@3.972.10': - resolution: {integrity: sha512-Gli9A0u8EVVb+5bFDGS/QbSVg28w/wpEidg1ggVcSj65BDTdGR6punsOcVjqdiu1i42WHWo51MCvARPIIz9juw==} + '@aws-sdk/token-providers@3.1074.0': + resolution: {integrity: sha512-pv80IzgGW4RnXWtft692chZOM9i6PhebVsLCcnaM4dBEPZva2fE6FXAHs76G7Rc7s3yGyX/68G0nZMrUy+Vmpg==} engines: {node: '>=20.0.0'} - '@aws-sdk/nested-clients@3.997.9': - resolution: {integrity: sha512-jPR3rnmRI4hWYyzfmTGBr7NblMp8QYYeflHXba1H6+7CGrWVqWKQzaXFQ4qbExqPRsXN3T3L3JxFhr6aouXUGQ==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/signature-v4-multi-region@3.996.27': - resolution: {integrity: sha512-0Phbz4t6HI3D3skxvG2uI+VWU034/nSIw1T8d+FPzzQG9EQTrw94o9mOKO2Gv3n3Oc8P7JD7RAUxkoneLWv5Eg==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/token-providers@3.1048.0': - resolution: {integrity: sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/types@3.973.8': - resolution: {integrity: sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==} + '@aws-sdk/types@3.973.13': + resolution: {integrity: sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg==} engines: {node: '>=20.0.0'} '@aws-sdk/util-locate-window@3.965.5': resolution: {integrity: sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/xml-builder@3.972.24': - resolution: {integrity: sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw==} + '@aws-sdk/xml-builder@3.972.31': + resolution: {integrity: sha512-SzE4Pgyl+hDF+BuyuzxUSpwnuUu9lJuO1YGgteG89/4Qv0+2IQiVQqdbPV32IozLvXWQChPQcdkk/sKvb1QHiQ==} engines: {node: '>=20.0.0'} '@aws/lambda-invoke-store@0.2.4': resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} engines: {node: '>=18.0.0'} - '@axe-core/playwright@4.11.3': - resolution: {integrity: sha512-h/kfksv4F0cVIDlKpT4700OehdRgpvuVskuQ2nb7/JmtWUXpe9ftHAPtwyXGvVSsa6SJ64A9ER7Zrzc/sIvC4w==} + '@axe-core/playwright@4.12.1': + resolution: {integrity: sha512-rMd7xriptqKpP+w5265i4Hdkv2X5kbu6uiBi/B2I7uf3hieRBM3qDCfaKPtxfiYb2mKXfF+yLODJwIx+Jv1GDw==} peerDependencies: playwright-core: '>= 1.0.0' @@ -1223,8 +1206,8 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 - '@next/bundle-analyzer@16.2.6': - resolution: {integrity: sha512-amPkVtHCTJAdBwyhhl5+qztHk24O4JlASgrWqh15AmnYi74apfZR46NGC0u4pM6BMAU1mYld4WdzD3cRBP3dOA==} + '@next/bundle-analyzer@16.2.9': + resolution: {integrity: sha512-yGWyLbC8MMn+hk9j6l6GammpbUz5S3yZzl3lpoWfVXhIpJfh6kAWG7JwF4WoG3f0VnYRY959yo1QQEI8mV/+jg==} '@next/env@16.2.9': resolution: {integrity: sha512-ki5VxxXfzD/9TDe13wyeTKIjQTAwBVpnr8KhRDUr8ltMUq1/NBpWNT5tiPoxiGl+PHM4X2ahSOiPk6iAimIzPg==} @@ -1300,9 +1283,6 @@ packages: resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} engines: {node: '>= 20.19.0'} - '@nodable/entities@2.1.0': - resolution: {integrity: sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==} - '@node-rs/argon2-android-arm-eabi@2.0.2': resolution: {integrity: sha512-DV/H8p/jt40lrao5z5g6nM9dPNPGEHL+aK6Iy/og+dbL503Uj0AHLqj1Hk9aVUSCNnsDdUEKp4TVMi0YakDYKw==} engines: {node: '>= 10'} @@ -2734,32 +2714,32 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} - '@smithy/core@3.24.3': - resolution: {integrity: sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg==} + '@smithy/core@3.26.0': + resolution: {integrity: sha512-mLUktFAn+Pa2agl1J7VgtYNFWCX8/b4GMJSK1hCu4YCvtBfM6F8Os3EP4ry+DFFlXOf3wyvlgXhuUdFoy52D3g==} engines: {node: '>=18.0.0'} - '@smithy/credential-provider-imds@4.3.3': - resolution: {integrity: sha512-I2Bti0DKFo2IJyN28ijCsx51BAumEYR4/1yZ1FXyBygy9MqbnMqCev4JPth/MbpRfBSRAX35hITSnAdJRo1u5w==} + '@smithy/credential-provider-imds@4.4.2': + resolution: {integrity: sha512-18UMDMyrAbDcpmL1gLUA7ww0fRTcdCrSjSJOi2Sbld+tVjwD/pW+OAwjlScFLR7vvBnhZrIPQ7kVuTf1mnJLug==} engines: {node: '>=18.0.0'} - '@smithy/fetch-http-handler@5.4.3': - resolution: {integrity: sha512-F+DRf8IJazRJgYog2A/yJK7eYVc0rqTlRzO+5ZxjJd4WkZoKz0IJRncf7G6t1pdVT3kryJcwuTFhN1c5m6N47A==} + '@smithy/fetch-http-handler@5.5.2': + resolution: {integrity: sha512-Ei/UK/QMhq0rKaMqGPlOAkE2yS9DZeYmZdk1RAKc3vp3zxgleZHZyBLlZv8yLsxljX4svCRuMTD6u3LLIcU4Bg==} engines: {node: '>=18.0.0'} '@smithy/is-array-buffer@2.2.0': resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} engines: {node: '>=14.0.0'} - '@smithy/node-http-handler@4.7.3': - resolution: {integrity: sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==} + '@smithy/node-http-handler@4.8.2': + resolution: {integrity: sha512-wfl1uwrAqMH9/pi4kqBo5LBcFwrJLxuDLqL7p7qNcJIFcyZDUc6pzhYk4CYv+DP7fIUpQCZumwNnkhPKS52osQ==} engines: {node: '>=18.0.0'} - '@smithy/signature-v4@5.4.3': - resolution: {integrity: sha512-53+75QuPl6DL+ct6vVEB51FDO5oulXr20TPV46VvJZg76lIlXNWfxi8j+G2V/t0I2qxCBOa3vX/8bmjrpFVo9g==} + '@smithy/signature-v4@5.5.2': + resolution: {integrity: sha512-7xHpmPY4rt0IOmeAA8EfjgEH8isT+587TCdy9H6a7d4OMi5CQ0oEHhWllunvPu4j4Cq0vTFwdxXN/kABWPjdyA==} engines: {node: '>=18.0.0'} - '@smithy/types@4.14.2': - resolution: {integrity: sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==} + '@smithy/types@4.15.0': + resolution: {integrity: sha512-Z5TAOxygoFvybJV3igo5SloFflSokHx2hu1eFA+DxDTcn+FtKxUSui+rbTRG1pAafMA888Z3MVvCWUuvCrTXjg==} engines: {node: '>=18.0.0'} '@smithy/util-buffer-from@2.2.0': @@ -3377,6 +3357,10 @@ packages: resolution: {integrity: sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==} engines: {node: '>=4'} + axe-core@4.12.1: + resolution: {integrity: sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==} + engines: {node: '>=4'} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -4251,13 +4235,6 @@ packages: fast-wrap-ansi@0.2.2: resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} - fast-xml-builder@1.2.0: - resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} - - fast-xml-parser@5.7.3: - resolution: {integrity: sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==} - hasBin: true - fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -5476,10 +5453,6 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} - path-expression-matcher@1.5.0: - resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} - engines: {node: '>=14.0.0'} - path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -5696,8 +5669,8 @@ packages: prettier-plugin-svelte: optional: true - prettier@3.8.4: - resolution: {integrity: sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==} + prettier@3.9.1: + resolution: {integrity: sha512-ppiDo2CSwexck1eyZUwJHg/N3nf1+6IRCv7W/VJ5vaLnVCmB7+3CdRfMwoCHBBX6xTrREDTksZ4OZl5SSf4zXA==} engines: {node: '>=14'} hasBin: true @@ -6261,9 +6234,6 @@ packages: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} - strnum@2.3.0: - resolution: {integrity: sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==} - styled-jsx@5.1.6: resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} engines: {node: '>= 12.0.0'} @@ -6328,9 +6298,6 @@ packages: tesseract.js@7.0.0: resolution: {integrity: sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==} - testcontainers@12.0.1: - resolution: {integrity: sha512-EMjjfMNJf3HlL7V3elkxqKUO1r3CtqNBTdmKGwwma/lOtUGfoWvFJ0WQ/KQf1DHEMnRjLWzW4cXbv/Tndsbcbw==} - testcontainers@12.0.3: resolution: {integrity: sha512-6vfiL9CWIX0rWhTJitzrzHcv4Q/sUfUdBV12jMz6HZ58Lz6ZtXVecO6jljd1O3ookEWQHNICiYoYNEPpDLzuRw==} @@ -6482,10 +6449,6 @@ packages: undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} - undici@7.26.0: - resolution: {integrity: sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg==} - engines: {node: '>=20.18.1'} - undici@8.5.0: resolution: {integrity: sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==} engines: {node: '>=22.19.0'} @@ -6748,10 +6711,6 @@ packages: resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} engines: {node: '>=20'} - xml-naming@0.1.0: - resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} - engines: {node: '>=16.0.0'} - xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -6851,20 +6810,20 @@ snapshots: '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.8 + '@aws-sdk/types': 3.973.13 tslib: 2.8.1 '@aws-crypto/crc32c@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.8 + '@aws-sdk/types': 3.973.13 tslib: 2.8.1 '@aws-crypto/sha1-browser@5.2.0': dependencies: '@aws-crypto/supports-web-crypto': 5.2.0 '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.8 + '@aws-sdk/types': 3.973.13 '@aws-sdk/util-locate-window': 3.965.5 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 @@ -6874,7 +6833,7 @@ snapshots: '@aws-crypto/sha256-js': 5.2.0 '@aws-crypto/supports-web-crypto': 5.2.0 '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.8 + '@aws-sdk/types': 3.973.13 '@aws-sdk/util-locate-window': 3.965.5 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 @@ -6882,7 +6841,7 @@ snapshots: '@aws-crypto/sha256-js@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.8 + '@aws-sdk/types': 3.973.13 tslib: 2.8.1 '@aws-crypto/supports-web-crypto@5.2.0': @@ -6891,231 +6850,195 @@ snapshots: '@aws-crypto/util@5.2.0': dependencies: - '@aws-sdk/types': 3.973.8 + '@aws-sdk/types': 3.973.13 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 - '@aws-sdk/client-s3@3.1048.0': - dependencies: - '@aws-crypto/sha1-browser': 5.2.0 - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.974.11 - '@aws-sdk/credential-provider-node': 3.972.42 - '@aws-sdk/middleware-bucket-endpoint': 3.972.13 - '@aws-sdk/middleware-expect-continue': 3.972.12 - '@aws-sdk/middleware-flexible-checksums': 3.974.19 - '@aws-sdk/middleware-location-constraint': 3.972.10 - '@aws-sdk/middleware-sdk-s3': 3.972.40 - '@aws-sdk/middleware-ssec': 3.972.10 - '@aws-sdk/signature-v4-multi-region': 3.996.27 - '@aws-sdk/types': 3.973.8 - '@smithy/core': 3.24.3 - '@smithy/fetch-http-handler': 5.4.3 - '@smithy/node-http-handler': 4.7.3 - '@smithy/types': 4.14.2 - tslib: 2.8.1 - - '@aws-sdk/core@3.974.11': - dependencies: - '@aws-sdk/types': 3.973.8 - '@aws-sdk/xml-builder': 3.972.24 - '@aws/lambda-invoke-store': 0.2.4 - '@smithy/core': 3.24.3 - '@smithy/signature-v4': 5.4.3 - '@smithy/types': 4.14.2 - bowser: 2.14.1 - tslib: 2.8.1 - - '@aws-sdk/crc64-nvme@3.972.8': - dependencies: - '@smithy/types': 4.14.2 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-env@3.972.37': - dependencies: - '@aws-sdk/core': 3.974.11 - '@aws-sdk/types': 3.973.8 - '@smithy/core': 3.24.3 - '@smithy/types': 4.14.2 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-http@3.972.39': - dependencies: - '@aws-sdk/core': 3.974.11 - '@aws-sdk/types': 3.973.8 - '@smithy/core': 3.24.3 - '@smithy/fetch-http-handler': 5.4.3 - '@smithy/node-http-handler': 4.7.3 - '@smithy/types': 4.14.2 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-ini@3.972.41': - dependencies: - '@aws-sdk/core': 3.974.11 - '@aws-sdk/credential-provider-env': 3.972.37 - '@aws-sdk/credential-provider-http': 3.972.39 - '@aws-sdk/credential-provider-login': 3.972.41 - '@aws-sdk/credential-provider-process': 3.972.37 - '@aws-sdk/credential-provider-sso': 3.972.41 - '@aws-sdk/credential-provider-web-identity': 3.972.41 - '@aws-sdk/nested-clients': 3.997.9 - '@aws-sdk/types': 3.973.8 - '@smithy/core': 3.24.3 - '@smithy/credential-provider-imds': 4.3.3 - '@smithy/types': 4.14.2 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-login@3.972.41': - dependencies: - '@aws-sdk/core': 3.974.11 - '@aws-sdk/nested-clients': 3.997.9 - '@aws-sdk/types': 3.973.8 - '@smithy/core': 3.24.3 - '@smithy/types': 4.14.2 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-node@3.972.42': - dependencies: - '@aws-sdk/credential-provider-env': 3.972.37 - '@aws-sdk/credential-provider-http': 3.972.39 - '@aws-sdk/credential-provider-ini': 3.972.41 - '@aws-sdk/credential-provider-process': 3.972.37 - '@aws-sdk/credential-provider-sso': 3.972.41 - '@aws-sdk/credential-provider-web-identity': 3.972.41 - '@aws-sdk/types': 3.973.8 - '@smithy/core': 3.24.3 - '@smithy/credential-provider-imds': 4.3.3 - '@smithy/types': 4.14.2 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-process@3.972.37': - dependencies: - '@aws-sdk/core': 3.974.11 - '@aws-sdk/types': 3.973.8 - '@smithy/core': 3.24.3 - '@smithy/types': 4.14.2 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-sso@3.972.41': - dependencies: - '@aws-sdk/core': 3.974.11 - '@aws-sdk/nested-clients': 3.997.9 - '@aws-sdk/token-providers': 3.1048.0 - '@aws-sdk/types': 3.973.8 - '@smithy/core': 3.24.3 - '@smithy/types': 4.14.2 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-web-identity@3.972.41': - dependencies: - '@aws-sdk/core': 3.974.11 - '@aws-sdk/nested-clients': 3.997.9 - '@aws-sdk/types': 3.973.8 - '@smithy/core': 3.24.3 - '@smithy/types': 4.14.2 - tslib: 2.8.1 - - '@aws-sdk/middleware-bucket-endpoint@3.972.13': - dependencies: - '@aws-sdk/core': 3.974.11 - '@aws-sdk/types': 3.973.8 - '@smithy/core': 3.24.3 - '@smithy/types': 4.14.2 - tslib: 2.8.1 - - '@aws-sdk/middleware-expect-continue@3.972.12': - dependencies: - '@aws-sdk/types': 3.973.8 - '@smithy/core': 3.24.3 - '@smithy/types': 4.14.2 - tslib: 2.8.1 - - '@aws-sdk/middleware-flexible-checksums@3.974.19': + '@aws-sdk/checksums@3.1000.8': dependencies: '@aws-crypto/crc32': 5.2.0 '@aws-crypto/crc32c': 5.2.0 '@aws-crypto/util': 5.2.0 - '@aws-sdk/core': 3.974.11 - '@aws-sdk/crc64-nvme': 3.972.8 - '@aws-sdk/types': 3.973.8 - '@smithy/core': 3.24.3 - '@smithy/types': 4.14.2 + '@aws-sdk/core': 3.974.23 + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.26.0 + '@smithy/types': 4.15.0 tslib: 2.8.1 - '@aws-sdk/middleware-location-constraint@3.972.10': + '@aws-sdk/client-s3@3.1075.0': dependencies: - '@aws-sdk/types': 3.973.8 - '@smithy/types': 4.14.2 + '@aws-crypto/sha1-browser': 5.2.0 + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.23 + '@aws-sdk/credential-provider-node': 3.972.58 + '@aws-sdk/middleware-flexible-checksums': 3.974.33 + '@aws-sdk/middleware-sdk-s3': 3.972.54 + '@aws-sdk/signature-v4-multi-region': 3.996.35 + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.26.0 + '@smithy/fetch-http-handler': 5.5.2 + '@smithy/node-http-handler': 4.8.2 + '@smithy/types': 4.15.0 tslib: 2.8.1 - '@aws-sdk/middleware-sdk-s3@3.972.40': + '@aws-sdk/core@3.974.23': dependencies: - '@aws-sdk/core': 3.974.11 - '@aws-sdk/signature-v4-multi-region': 3.996.27 - '@aws-sdk/types': 3.973.8 - '@smithy/core': 3.24.3 - '@smithy/signature-v4': 5.4.3 - '@smithy/types': 4.14.2 + '@aws-sdk/types': 3.973.13 + '@aws-sdk/xml-builder': 3.972.31 + '@aws/lambda-invoke-store': 0.2.4 + '@smithy/core': 3.26.0 + '@smithy/signature-v4': 5.5.2 + '@smithy/types': 4.15.0 + bowser: 2.14.1 tslib: 2.8.1 - '@aws-sdk/middleware-ssec@3.972.10': + '@aws-sdk/credential-provider-env@3.972.49': dependencies: - '@aws-sdk/types': 3.973.8 - '@smithy/types': 4.14.2 + '@aws-sdk/core': 3.974.23 + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.26.0 + '@smithy/types': 4.15.0 tslib: 2.8.1 - '@aws-sdk/nested-clients@3.997.9': + '@aws-sdk/credential-provider-http@3.972.51': + dependencies: + '@aws-sdk/core': 3.974.23 + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.26.0 + '@smithy/fetch-http-handler': 5.5.2 + '@smithy/node-http-handler': 4.8.2 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.972.56': + dependencies: + '@aws-sdk/core': 3.974.23 + '@aws-sdk/credential-provider-env': 3.972.49 + '@aws-sdk/credential-provider-http': 3.972.51 + '@aws-sdk/credential-provider-login': 3.972.55 + '@aws-sdk/credential-provider-process': 3.972.49 + '@aws-sdk/credential-provider-sso': 3.972.55 + '@aws-sdk/credential-provider-web-identity': 3.972.55 + '@aws-sdk/nested-clients': 3.997.23 + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.26.0 + '@smithy/credential-provider-imds': 4.4.2 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-login@3.972.55': + dependencies: + '@aws-sdk/core': 3.974.23 + '@aws-sdk/nested-clients': 3.997.23 + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.26.0 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-node@3.972.58': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.49 + '@aws-sdk/credential-provider-http': 3.972.51 + '@aws-sdk/credential-provider-ini': 3.972.56 + '@aws-sdk/credential-provider-process': 3.972.49 + '@aws-sdk/credential-provider-sso': 3.972.55 + '@aws-sdk/credential-provider-web-identity': 3.972.55 + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.26.0 + '@smithy/credential-provider-imds': 4.4.2 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-process@3.972.49': + dependencies: + '@aws-sdk/core': 3.974.23 + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.26.0 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.972.55': + dependencies: + '@aws-sdk/core': 3.974.23 + '@aws-sdk/nested-clients': 3.997.23 + '@aws-sdk/token-providers': 3.1074.0 + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.26.0 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-web-identity@3.972.55': + dependencies: + '@aws-sdk/core': 3.974.23 + '@aws-sdk/nested-clients': 3.997.23 + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.26.0 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-flexible-checksums@3.974.33': + dependencies: + '@aws-sdk/checksums': 3.1000.8 + tslib: 2.8.1 + + '@aws-sdk/middleware-sdk-s3@3.972.54': + dependencies: + '@aws-sdk/core': 3.974.23 + '@aws-sdk/signature-v4-multi-region': 3.996.35 + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.26.0 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.997.23': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.974.11 - '@aws-sdk/signature-v4-multi-region': 3.996.27 - '@aws-sdk/types': 3.973.8 - '@smithy/core': 3.24.3 - '@smithy/fetch-http-handler': 5.4.3 - '@smithy/node-http-handler': 4.7.3 - '@smithy/types': 4.14.2 + '@aws-sdk/core': 3.974.23 + '@aws-sdk/signature-v4-multi-region': 3.996.35 + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.26.0 + '@smithy/fetch-http-handler': 5.5.2 + '@smithy/node-http-handler': 4.8.2 + '@smithy/types': 4.15.0 tslib: 2.8.1 - '@aws-sdk/signature-v4-multi-region@3.996.27': + '@aws-sdk/signature-v4-multi-region@3.996.35': dependencies: - '@aws-sdk/types': 3.973.8 - '@smithy/core': 3.24.3 - '@smithy/signature-v4': 5.4.3 - '@smithy/types': 4.14.2 + '@aws-sdk/types': 3.973.13 + '@smithy/signature-v4': 5.5.2 + '@smithy/types': 4.15.0 tslib: 2.8.1 - '@aws-sdk/token-providers@3.1048.0': + '@aws-sdk/token-providers@3.1074.0': dependencies: - '@aws-sdk/core': 3.974.11 - '@aws-sdk/nested-clients': 3.997.9 - '@aws-sdk/types': 3.973.8 - '@smithy/core': 3.24.3 - '@smithy/types': 4.14.2 + '@aws-sdk/core': 3.974.23 + '@aws-sdk/nested-clients': 3.997.23 + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.26.0 + '@smithy/types': 4.15.0 tslib: 2.8.1 - '@aws-sdk/types@3.973.8': + '@aws-sdk/types@3.973.13': dependencies: - '@smithy/types': 4.14.2 + '@smithy/types': 4.15.0 tslib: 2.8.1 '@aws-sdk/util-locate-window@3.965.5': dependencies: tslib: 2.8.1 - '@aws-sdk/xml-builder@3.972.24': + '@aws-sdk/xml-builder@3.972.31': dependencies: - '@nodable/entities': 2.1.0 - '@smithy/types': 4.14.2 - fast-xml-parser: 5.7.3 + '@smithy/types': 4.15.0 tslib: 2.8.1 '@aws/lambda-invoke-store@0.2.4': {} - '@axe-core/playwright@4.11.3(playwright-core@1.60.0)': + '@axe-core/playwright@4.12.1(playwright-core@1.60.0)': dependencies: - axe-core: 4.11.4 + axe-core: 4.12.1 playwright-core: 1.60.0 '@babel/code-frame@7.29.0': @@ -7968,7 +7891,7 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true - '@next/bundle-analyzer@16.2.6': + '@next/bundle-analyzer@16.2.9': dependencies: webpack-bundle-analyzer: 4.10.1 transitivePeerDependencies: @@ -8015,8 +7938,6 @@ snapshots: '@noble/hashes@2.2.0': {} - '@nodable/entities@2.1.0': {} - '@node-rs/argon2-android-arm-eabi@2.0.2': optional: true @@ -9373,41 +9294,41 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} - '@smithy/core@3.24.3': + '@smithy/core@3.26.0': dependencies: '@aws-crypto/crc32': 5.2.0 - '@smithy/types': 4.14.2 + '@smithy/types': 4.15.0 tslib: 2.8.1 - '@smithy/credential-provider-imds@4.3.3': + '@smithy/credential-provider-imds@4.4.2': dependencies: - '@smithy/core': 3.24.3 - '@smithy/types': 4.14.2 + '@smithy/core': 3.26.0 + '@smithy/types': 4.15.0 tslib: 2.8.1 - '@smithy/fetch-http-handler@5.4.3': + '@smithy/fetch-http-handler@5.5.2': dependencies: - '@smithy/core': 3.24.3 - '@smithy/types': 4.14.2 + '@smithy/core': 3.26.0 + '@smithy/types': 4.15.0 tslib: 2.8.1 '@smithy/is-array-buffer@2.2.0': dependencies: tslib: 2.8.1 - '@smithy/node-http-handler@4.7.3': + '@smithy/node-http-handler@4.8.2': dependencies: - '@smithy/core': 3.24.3 - '@smithy/types': 4.14.2 + '@smithy/core': 3.26.0 + '@smithy/types': 4.15.0 tslib: 2.8.1 - '@smithy/signature-v4@5.4.3': + '@smithy/signature-v4@5.5.2': dependencies: - '@smithy/core': 3.24.3 - '@smithy/types': 4.14.2 + '@smithy/core': 3.26.0 + '@smithy/types': 4.15.0 tslib: 2.8.1 - '@smithy/types@4.14.2': + '@smithy/types@4.15.0': dependencies: tslib: 2.8.1 @@ -9579,13 +9500,13 @@ snapshots: '@types/docker-modem@3.0.6': dependencies: - '@types/node': 18.19.130 + '@types/node': 25.9.2 '@types/ssh2': 1.15.5 '@types/dockerode@4.0.1': dependencies: '@types/docker-modem': 3.0.6 - '@types/node': 18.19.130 + '@types/node': 25.9.2 '@types/ssh2': 1.15.5 '@types/estree@1.0.8': {} @@ -9641,11 +9562,11 @@ snapshots: '@types/ssh2-streams@0.1.13': dependencies: - '@types/node': 18.19.130 + '@types/node': 25.9.2 '@types/ssh2@0.5.52': dependencies: - '@types/node': 18.19.130 + '@types/node': 25.9.2 '@types/ssh2-streams': 0.1.13 '@types/ssh2@1.15.5': @@ -10080,6 +10001,8 @@ snapshots: axe-core@4.11.4: {} + axe-core@4.12.1: {} + axobject-query@4.1.0: {} b4a@1.8.1: {} @@ -11114,18 +11037,6 @@ snapshots: dependencies: fast-string-width: 3.0.2 - fast-xml-builder@1.2.0: - dependencies: - path-expression-matcher: 1.5.0 - xml-naming: 0.1.0 - - fast-xml-parser@5.7.3: - dependencies: - '@nodable/entities': 2.1.0 - fast-xml-builder: 1.2.0 - path-expression-matcher: 1.5.0 - strnum: 2.3.0 - fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -12298,8 +12209,6 @@ snapshots: path-exists@4.0.0: {} - path-expression-matcher@1.5.0: {} - path-key@3.1.1: {} path-key@4.0.0: {} @@ -12441,11 +12350,11 @@ snapshots: prelude-ls@1.2.1: {} - prettier-plugin-tailwindcss@0.8.0(prettier@3.8.4): + prettier-plugin-tailwindcss@0.8.0(prettier@3.9.1): dependencies: - prettier: 3.8.4 + prettier: 3.9.1 - prettier@3.8.4: {} + prettier@3.9.1: {} pretty-ms@9.3.0: dependencies: @@ -12508,7 +12417,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.1 - '@types/node': 18.19.130 + '@types/node': 25.9.2 long: 5.3.2 proxy-addr@2.0.7: @@ -13234,8 +13143,6 @@ snapshots: strip-json-comments@5.0.3: {} - strnum@2.3.0: {} - styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.2.7): dependencies: client-only: 0.0.1 @@ -13323,29 +13230,6 @@ snapshots: transitivePeerDependencies: - encoding - testcontainers@12.0.1: - dependencies: - '@balena/dockerignore': 1.0.2 - '@types/dockerode': 4.0.1 - archiver: 7.0.1 - async-lock: 1.4.1 - byline: 5.0.0 - debug: 4.4.3 - docker-compose: 1.4.2 - dockerode: 5.0.0 - get-port: 7.2.0 - proper-lockfile: 4.1.2 - properties-reader: 3.0.1 - ssh-remote-port-forward: 1.0.4 - tar-fs: 3.1.2 - tmp: 0.2.7 - undici: 7.26.0 - transitivePeerDependencies: - - bare-abort-controller - - bare-buffer - - react-native-b4a - - supports-color - testcontainers@12.0.3: dependencies: '@balena/dockerignore': 1.0.2 @@ -13531,8 +13415,6 @@ snapshots: undici-types@7.24.6: {} - undici@7.26.0: {} - undici@8.5.0: {} unicorn-magic@0.3.0: {} @@ -13804,8 +13686,6 @@ snapshots: is-wsl: 3.1.1 powershell-utils: 0.1.0 - xml-naming@0.1.0: {} - xtend@4.0.2: {} y18n@4.0.3: {} diff --git a/prisma/migrations/0208_v125_med_notes_encryption/migration.sql b/prisma/migrations/0208_v125_med_notes_encryption/migration.sql new file mode 100644 index 000000000..f711bdd0d --- /dev/null +++ b/prisma/migrations/0208_v125_med_notes_encryption/migration.sql @@ -0,0 +1,35 @@ +-- v1.25 — encrypt medication free-text notes at rest. +-- +-- Adds the AES-256-GCM ciphertext columns for the three medication +-- free-text fields that were still plaintext: the side-effect note +-- (`medication_side_effects.notes`), the dose-change titration note +-- (`medication_dose_changes.note`), and the inventory-item note +-- (`medication_inventory_items.notes`). These were the last plaintext +-- PHI columns left after the v1.23 note-encryption rollout. +-- +-- STORAGE CONVENTION: Bytes (`bytea`), matching the dominant free-text +-- encrypted-note convention already in the schema (mood_entries.note_ +-- encrypted, measurements.notes_encrypted, illness_day_logs.note_encrypted, +-- labs.note_encrypted, coach_*.{...}_encrypted) — the `encrypt()` +-- ciphertext string stored UTF-8 as `bytea`, the shape the rotation +-- script's `rotateBytesColumn` already covers. All three columns are +-- registered in ENCRYPTED_COLUMNS + the rotation script in the same change. +-- +-- This migration is ADDITIVE ONLY. The legacy plaintext columns +-- (`medication_side_effects.notes`, `medication_dose_changes.note`, +-- `medication_inventory_items.notes`) are intentionally NOT dropped here: +-- an idempotent, transactional, fail-closed boot-time job encrypts the +-- existing rows (plaintext -> ciphertext, then nulls the plaintext) so the +-- read path can fall back to plaintext for not-yet-migrated rows. Dropping +-- the plaintext columns is a deliberate FOLLOW-UP release, once the backfill +-- reports zero remaining plaintext-but-no-ciphertext rows — the same +-- boundary the encryption-key-rotation playbook uses before dropping a +-- legacy key. +ALTER TABLE "medication_side_effects" + ADD COLUMN IF NOT EXISTS "notes_encrypted" BYTEA; + +ALTER TABLE "medication_dose_changes" + ADD COLUMN IF NOT EXISTS "note_encrypted" BYTEA; + +ALTER TABLE "medication_inventory_items" + ADD COLUMN IF NOT EXISTS "notes_encrypted" BYTEA; diff --git a/prisma/migrations/0210_v125_measurement_useractivity_index/migration.sql b/prisma/migrations/0210_v125_measurement_useractivity_index/migration.sql new file mode 100644 index 000000000..436837646 --- /dev/null +++ b/prisma/migrations/0210_v125_measurement_useractivity_index/migration.sql @@ -0,0 +1,30 @@ +-- v1.25 — type-less `(user_id, measured_at)` supporting index on `measurements`. +-- +-- The bulk feature read (`extractFeatures`, src/lib/insights/features.ts) and +-- the consolidation scans walk a user's measurements ordered by `measured_at` +-- with no `type` predicate. The existing composite indexes cover +-- `(user_id, type, measured_at)`, `(user_id, source, measured_at)`, and +-- `(user_id, updated_at, id)` — none serves a type-less `(user_id, measured_at)` +-- range scan as a prefix. Without this index Postgres filters by `user_id` then +-- sorts the user's entire measurement set in memory on each of those reads, +-- which on a multi-year HealthKit tenant is hundreds of thousands of rows. +-- +-- The composite `(user_id, measured_at)` index lets those reads stream straight +-- off the index in time order, which the newest-first row caps then bound. +-- +-- 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 slow; 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_measured_at_idx"; + +CREATE INDEX IF NOT EXISTS "measurements_user_id_measured_at_idx" + ON "measurements" ("user_id", "measured_at"); diff --git a/prisma/migrations/0211_v125_med_pause_era/migration.sql b/prisma/migrations/0211_v125_med_pause_era/migration.sql new file mode 100644 index 000000000..11a18e6f1 --- /dev/null +++ b/prisma/migrations/0211_v125_med_pause_era/migration.sql @@ -0,0 +1,31 @@ +-- v1.25 — durable medication pause intervals (H-MED1). +-- +-- The `medications.paused_at` column is a single live marker: resume clears +-- it, so the paused window is irrecoverable once the medication resumes. +-- After a resume the compliance denominator spanned created_at..now and every +-- paused day's expected slot collapsed to "missed", deflating adherence. +-- +-- `medication_pause_eras` records each pause as its own durable row: a pause +-- stamps an open era (`resumed_at` NULL), resume closes the latest open era +-- (`resumed_at` = now). The compliance engine drops expected dose slots whose +-- anchor falls inside any `[paused_at, resumed_at ?? now)` interval so a +-- resumed medication never counts the paused days as missed. The `paused_at` +-- marker column on `medications` is left intact — current readers depend on it. +CREATE TABLE "medication_pause_eras" ( + "id" TEXT NOT NULL, + "medication_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "paused_at" TIMESTAMPTZ(6) NOT NULL, + "resumed_at" TIMESTAMPTZ(6), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "medication_pause_eras_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "medication_pause_eras_medication_id_idx" ON "medication_pause_eras"("medication_id"); + +CREATE INDEX "medication_pause_eras_user_id_idx" ON "medication_pause_eras"("user_id"); + +ALTER TABLE "medication_pause_eras" ADD CONSTRAINT "medication_pause_eras_medication_id_fkey" FOREIGN KEY ("medication_id") REFERENCES "medications"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "medication_pause_eras" ADD CONSTRAINT "medication_pause_eras_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/0213_v125_mental_health_assessment/migration.sql b/prisma/migrations/0213_v125_mental_health_assessment/migration.sql new file mode 100644 index 000000000..5981f1312 --- /dev/null +++ b/prisma/migrations/0213_v125_mental_health_assessment/migration.sql @@ -0,0 +1,46 @@ +-- v1.25 — clinical-signals wave: validated mental-health screeners (PHQ-9 + GAD-7). +-- +-- Adds the two derived total-score MeasurementType members and the +-- `mental_health_assessments` table that holds one completed administration per +-- row. The per-item answers ride a single AES-256-GCM `bytea` blob +-- (`responses_encrypted`); only the derived total / band / item-9 flag are +-- stored in the clear, for cheap history reads. Opt-in, beside mood tracking. + +-- New MeasurementType members for the derived scores. ADD VALUE IF NOT EXISTS is +-- idempotent and not used within this migration, so the in-transaction guard +-- does not apply. +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'PHQ9_SCORE'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'GAD7_SCORE'; + +-- Instrument discriminator. +CREATE TYPE "assessment_instrument" AS ENUM ('PHQ9', 'GAD7'); + +CREATE TABLE "mental_health_assessments" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "instrument" "assessment_instrument" NOT NULL, + "locale" TEXT NOT NULL, + "version" TEXT NOT NULL DEFAULT 'standard', + "responses_encrypted" BYTEA NOT NULL, + "total_score" INTEGER NOT NULL, + "severity_band" TEXT NOT NULL, + "item9_flagged" BOOLEAN NOT NULL DEFAULT false, + "crisis_shown_at" TIMESTAMP(3), + "taken_at" TIMESTAMP(3) NOT NULL, + "tz" TEXT, + "source" TEXT NOT NULL DEFAULT 'WEB', + "external_id" TEXT, + "sync_version" INTEGER NOT NULL DEFAULT 0, + "deleted_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "mental_health_assessments_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "mental_health_assessments_user_id_source_external_id_key" ON "mental_health_assessments"("user_id", "source", "external_id"); +CREATE INDEX "mental_health_assessments_user_id_instrument_taken_at_idx" ON "mental_health_assessments"("user_id", "instrument", "taken_at"); +CREATE INDEX "mental_health_assessments_user_id_updated_at_id_idx" ON "mental_health_assessments"("user_id", "updated_at", "id"); + +ALTER TABLE "mental_health_assessments" ADD CONSTRAINT "mental_health_assessments_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/0214_v125_physical_clinical_measurements/migration.sql b/prisma/migrations/0214_v125_physical_clinical_measurements/migration.sql new file mode 100644 index 000000000..3c97dfc7e --- /dev/null +++ b/prisma/migrations/0214_v125_physical_clinical_measurements/migration.sql @@ -0,0 +1,10 @@ +-- v1.25 — clinical-signals wave: physical / clinical measurement types. +-- +-- Grip strength (EWGSOP2 sarcopenia limb), pain Numeric Rating Scale (0–10), +-- waist circumference (WHO STEPS), and the waist-to-height ratio (WHtR). Each +-- is a first-class numeric Measurement type; bands/units are applied at the +-- display edge from the signal registry. ADD VALUE IF NOT EXISTS is idempotent. +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'GRIP_STRENGTH'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'PAIN_NRS'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'WAIST_CIRCUMFERENCE'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'WAIST_TO_HEIGHT'; diff --git a/prisma/migrations/0215_v125_allergies/migration.sql b/prisma/migrations/0215_v125_allergies/migration.sql new file mode 100644 index 000000000..25bd6dd11 --- /dev/null +++ b/prisma/migrations/0215_v125_allergies/migration.sql @@ -0,0 +1,81 @@ +-- v1.25 (W-RECORDS) — structured allergies / intolerances. +-- +-- A self-recorded AllergyIntolerance-style record: substance + category + +-- type + severity + status + optional onset, with the free-text reaction +-- description and notes encrypted at rest (BYTEA, the AES-256-GCM +-- `*_encrypted` convention shared with the illness / mood / lab note +-- columns). The `substance` label stays queryable plaintext (the +-- medication.name / illness.label precedent) — it rides the FHIR +-- AllergyIntolerance `code.text` anchor, never a machine-guessed code. +-- +-- Patient-reported reference data, not a time-series signal and not a +-- clinical diagnosis. Soft-deleted (`deleted_at`). Owner-scoped via the FK. +-- +-- Additive; no existing row touched. Idempotent guards (`IF NOT EXISTS` / +-- `DO $$`) so reruns are safe on prod. +-- +-- Reversibility (down): +-- DROP TABLE IF EXISTS "allergies"; +-- DROP TYPE IF EXISTS "allergy_status"; +-- DROP TYPE IF EXISTS "allergy_severity"; +-- DROP TYPE IF EXISTS "allergy_type"; +-- DROP TYPE IF EXISTS "allergy_category"; +-- A roll-back drops the allergy record wholesale — no other domain depends +-- on it. + +-- CreateEnum +DO $$ BEGIN + CREATE TYPE "allergy_category" AS ENUM ('FOOD', 'MEDICATION', 'ENVIRONMENT', 'BIOLOGIC', 'OTHER'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- CreateEnum +DO $$ BEGIN + CREATE TYPE "allergy_type" AS ENUM ('ALLERGY', 'INTOLERANCE'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- CreateEnum +DO $$ BEGIN + CREATE TYPE "allergy_severity" AS ENUM ('MILD', 'MODERATE', 'SEVERE'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- CreateEnum +DO $$ BEGIN + CREATE TYPE "allergy_status" AS ENUM ('ACTIVE', 'INACTIVE', 'RESOLVED'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- CreateTable +CREATE TABLE IF NOT EXISTS "allergies" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "substance" TEXT NOT NULL, + "category" "allergy_category" NOT NULL DEFAULT 'OTHER', + "type" "allergy_type" NOT NULL DEFAULT 'ALLERGY', + "severity" "allergy_severity", + "status" "allergy_status" NOT NULL DEFAULT 'ACTIVE', + "onset_at" TIMESTAMP(3), + "reaction_encrypted" BYTEA, + "notes_encrypted" BYTEA, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "deleted_at" TIMESTAMP(3), + + CONSTRAINT "allergies_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "allergies_user_id_deleted_at_idx" ON "allergies"("user_id", "deleted_at"); + +-- AddForeignKey +DO $$ BEGIN + ALTER TABLE "allergies" ADD CONSTRAINT "allergies_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/prisma/migrations/0216_v125_family_history/migration.sql b/prisma/migrations/0216_v125_family_history/migration.sql new file mode 100644 index 000000000..274579f1d --- /dev/null +++ b/prisma/migrations/0216_v125_family_history/migration.sql @@ -0,0 +1,58 @@ +-- v1.25 (W-RECORDS) — structured family history. +-- +-- One condition recorded for one relative (a FHIR FamilyMemberHistory with a +-- single condition): relationship + condition label + optional age-at-onset, +-- with the optional free-text note encrypted at rest (BYTEA, the AES-256-GCM +-- `*_encrypted` convention). The `condition` label stays queryable plaintext +-- (the medication.name / illness.label precedent) — it rides the FHIR +-- FamilyMemberHistory `condition.code.text` anchor, never a machine-guessed +-- code. +-- +-- Patient-reported reference data, not a time-series signal and not a clinical +-- diagnosis. Soft-deleted (`deleted_at`). Owner-scoped via the FK. +-- +-- Additive; no existing row touched. Idempotent guards (`IF NOT EXISTS` / +-- `DO $$`) so reruns are safe on prod. +-- +-- Reversibility (down): +-- DROP TABLE IF EXISTS "family_history_entries"; +-- DROP TYPE IF EXISTS "family_relationship"; +-- A roll-back drops the family-history record wholesale — no other domain +-- depends on it. + +-- CreateEnum +DO $$ BEGIN + CREATE TYPE "family_relationship" AS ENUM ( + 'MOTHER', 'FATHER', 'SISTER', 'BROTHER', 'DAUGHTER', 'SON', + 'GRANDMOTHER_MATERNAL', 'GRANDFATHER_MATERNAL', + 'GRANDMOTHER_PATERNAL', 'GRANDFATHER_PATERNAL', + 'AUNT', 'UNCLE', 'COUSIN', 'HALF_SIBLING', 'OTHER' + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- CreateTable +CREATE TABLE IF NOT EXISTS "family_history_entries" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "relationship" "family_relationship" NOT NULL, + "condition" TEXT NOT NULL, + "age_at_onset" INTEGER, + "notes_encrypted" BYTEA, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "deleted_at" TIMESTAMP(3), + + CONSTRAINT "family_history_entries_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "family_history_entries_user_id_deleted_at_idx" ON "family_history_entries"("user_id", "deleted_at"); + +-- AddForeignKey +DO $$ BEGIN + ALTER TABLE "family_history_entries" ADD CONSTRAINT "family_history_entries_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/prisma/migrations/0217_v125_environmental_context/migration.sql b/prisma/migrations/0217_v125_environmental_context/migration.sql new file mode 100644 index 000000000..4cc4e661f --- /dev/null +++ b/prisma/migrations/0217_v125_environmental_context/migration.sql @@ -0,0 +1,101 @@ +-- v1.25 (W-ENV) — environmental-context module (opt-in, server-side v1). +-- +-- Adds the per-day weather/daylight observation store and the manual travel +-- overrides, plus the coarse home-location columns on `users`. The module is +-- opt-in (default OFF via the module registry); with it off no row is written +-- and no outbound weather fetch runs. +-- +-- Location granularity is coarse (city level / rounded lat-lon) by design — the +-- weather model only needs ~10–25 km and HealthLog never stores a GPS track. +-- +-- Additive; no existing row touched. Idempotent guards (`IF NOT EXISTS` / +-- `DO $$`) so reruns are safe on prod. +-- +-- Reversibility (down): +-- DROP TABLE IF EXISTS "environment_travel_locations"; +-- DROP TABLE IF EXISTS "environment_contexts"; +-- DROP TYPE IF EXISTS "environment_location_source"; +-- ALTER TABLE "users" +-- DROP COLUMN IF EXISTS "home_lat", +-- DROP COLUMN IF EXISTS "home_lon", +-- DROP COLUMN IF EXISTS "home_label", +-- DROP COLUMN IF EXISTS "home_timezone"; +-- A roll-back drops the environment store wholesale — no other domain depends +-- on it. + +-- AlterTable: coarse home location on the user (all nullable). +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "home_lat" DOUBLE PRECISION; +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "home_lon" DOUBLE PRECISION; +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "home_label" TEXT; +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "home_timezone" TEXT; + +-- CreateEnum +DO $$ BEGIN + CREATE TYPE "environment_location_source" AS ENUM ('HOME', 'TRAVEL'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- CreateTable +CREATE TABLE IF NOT EXISTS "environment_contexts" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "date" TEXT NOT NULL, + "lat" DOUBLE PRECISION NOT NULL, + "lon" DOUBLE PRECISION NOT NULL, + "location_label" TEXT NOT NULL, + "source" "environment_location_source" NOT NULL, + "temp_min" DOUBLE PRECISION, + "temp_max" DOUBLE PRECISION, + "temp_mean" DOUBLE PRECISION, + "apparent_mean" DOUBLE PRECISION, + "sunshine_sec" INTEGER, + "daylight_sec" INTEGER, + "precip_sum" DOUBLE PRECISION, + "pressure_mean" DOUBLE PRECISION, + "pressure_delta" DOUBLE PRECISION, + "humidity_mean" DOUBLE PRECISION, + "cloud_mean" DOUBLE PRECISION, + "weather_code" INTEGER, + "fetched_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "environment_contexts_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX IF NOT EXISTS "environment_contexts_user_id_date_key" ON "environment_contexts"("user_id", "date"); +CREATE INDEX IF NOT EXISTS "environment_contexts_user_id_date_idx" ON "environment_contexts"("user_id", "date"); + +-- CreateTable +CREATE TABLE IF NOT EXISTS "environment_travel_locations" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "start_date" TEXT NOT NULL, + "end_date" TEXT NOT NULL, + "lat" DOUBLE PRECISION NOT NULL, + "lon" DOUBLE PRECISION NOT NULL, + "label" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "environment_travel_locations_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "environment_travel_locations_user_id_start_date_end_date_idx" ON "environment_travel_locations"("user_id", "start_date", "end_date"); + +-- AddForeignKey +DO $$ BEGIN + ALTER TABLE "environment_contexts" ADD CONSTRAINT "environment_contexts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- AddForeignKey +DO $$ BEGIN + ALTER TABLE "environment_travel_locations" ADD CONSTRAINT "environment_travel_locations_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/prisma/migrations/0218_v125_inbound_documents/migration.sql b/prisma/migrations/0218_v125_inbound_documents/migration.sql new file mode 100644 index 000000000..f753aa643 --- /dev/null +++ b/prisma/migrations/0218_v125_inbound_documents/migration.sql @@ -0,0 +1,130 @@ +-- v1.25 (W-DOCS-IN) — inbound clinical documents + extracted-fact staging. +-- +-- A self-hoster uploads a doctor report / discharge letter they received. +-- The raw document is stored encrypted at rest (BYTEA, the AES-256-GCM +-- `*_encrypted` convention shared with the illness / mood / lab note +-- columns). The dedicated OCR/vision provider transcribes STRUCTURED FACTS +-- into a review-then-confirm staging area (`extracted_facts`). The app +-- reproduces what the clinician wrote — it never interprets, never +-- range-flags, never diagnoses, never links a condition to a medication. A +-- fact reaches the structured stores (labs / conditions / medications) only +-- on explicit user approval; low-confidence facts fail closed. +-- +-- `mime_type`, `byte_size`, `provider_type`, `report_date` stay queryable +-- plaintext metadata; `content_encrypted` holds the sensitive original. The +-- per-fact `data_json` / `provenance_json` carry the FHIR-staged fields +-- (stated codes only) and the source-span provenance. +-- +-- Additive; no existing row touched. Idempotent guards (`IF NOT EXISTS` / +-- `DO $$`) so reruns are safe on prod. +-- +-- Reversibility (down): +-- DROP TABLE IF EXISTS "extracted_facts"; +-- DROP TABLE IF EXISTS "inbound_documents"; +-- DROP TYPE IF EXISTS "extracted_fact_status"; +-- DROP TYPE IF EXISTS "extracted_fact_type"; +-- DROP TYPE IF EXISTS "inbound_document_status"; +-- DROP TYPE IF EXISTS "inbound_document_kind"; +-- A roll-back drops the inbound-document store wholesale — facts already +-- approved into labs / conditions / medications are independent rows and are +-- NOT affected (the staging link is one-way). + +-- CreateEnum +DO $$ BEGIN + CREATE TYPE "inbound_document_kind" AS ENUM ('DOCTOR_REPORT', 'DISCHARGE_LETTER', 'OTHER'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- CreateEnum +DO $$ BEGIN + CREATE TYPE "inbound_document_status" AS ENUM ('EXTRACTING', 'EXTRACTED', 'FAILED', 'CONFIRMED', 'DISCARDED'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- CreateEnum +DO $$ BEGIN + CREATE TYPE "extracted_fact_type" AS ENUM ('CONDITION', 'OBSERVATION', 'MEDICATION_STATEMENT'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- CreateEnum +DO $$ BEGIN + CREATE TYPE "extracted_fact_status" AS ENUM ('PENDING', 'APPROVED', 'REJECTED'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- CreateTable +CREATE TABLE IF NOT EXISTS "inbound_documents" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "kind" "inbound_document_kind" NOT NULL DEFAULT 'OTHER', + "filename" TEXT, + "mime_type" TEXT NOT NULL, + "byte_size" INTEGER NOT NULL, + "content_encrypted" BYTEA NOT NULL, + "status" "inbound_document_status" NOT NULL DEFAULT 'EXTRACTING', + "provider_type" TEXT, + "report_date" TIMESTAMP(3), + "error_reason" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "deleted_at" TIMESTAMP(3), + + CONSTRAINT "inbound_documents_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE IF NOT EXISTS "extracted_facts" ( + "id" TEXT NOT NULL, + "document_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "fact_type" "extracted_fact_type" NOT NULL, + "status" "extracted_fact_status" NOT NULL DEFAULT 'PENDING', + "confidence" DOUBLE PRECISION NOT NULL DEFAULT 0, + "needs_review" BOOLEAN NOT NULL DEFAULT true, + "data_json" JSONB NOT NULL, + "provenance_json" JSONB NOT NULL, + "committed_record_id" TEXT, + "committed_record_type" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "extracted_facts_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "inbound_documents_user_id_created_at_idx" ON "inbound_documents"("user_id", "created_at"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "inbound_documents_user_id_deleted_at_idx" ON "inbound_documents"("user_id", "deleted_at"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "extracted_facts_document_id_idx" ON "extracted_facts"("document_id"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "extracted_facts_user_id_idx" ON "extracted_facts"("user_id"); + +-- AddForeignKey +DO $$ BEGIN + ALTER TABLE "inbound_documents" ADD CONSTRAINT "inbound_documents_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- AddForeignKey +DO $$ BEGIN + ALTER TABLE "extracted_facts" ADD CONSTRAINT "extracted_facts_document_id_fkey" FOREIGN KEY ("document_id") REFERENCES "inbound_documents"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- AddForeignKey +DO $$ BEGIN + ALTER TABLE "extracted_facts" ADD CONSTRAINT "extracted_facts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/prisma/migrations/0219_v125_env_home_since/migration.sql b/prisma/migrations/0219_v125_env_home_since/migration.sql new file mode 100644 index 000000000..16c2453d2 --- /dev/null +++ b/prisma/migrations/0219_v125_env_home_since/migration.sql @@ -0,0 +1,36 @@ +-- v1.25 (W-ENV) — conservative past-location handling for the environmental +-- context module. +-- +-- Adds `users.home_since` (effective-from instant for the home location) and a +-- `DEVICE` value to the location-source enum. Day resolution attributes a past +-- day to the home location ONLY when the day is on/after `home_since`; earlier +-- days are skipped rather than fabricated from the current home. The deep past +-- is filled by explicit dated location periods instead. +-- +-- `DEVICE` is the reserved forward path for a future client-supplied coarse +-- per-day location (iOS v2). The value exists so the resolver and the wire +-- contract already accept it; nothing writes it server-side yet. +-- +-- Backfill of existing accounts: any row that already has a home location gets +-- `home_since` stamped to the migration instant. This is conservative — the +-- exact day a pre-upgrade account moved into its home is unknown, so history +-- before the upgrade is left to explicit location periods rather than guessed. +-- +-- Additive; no existing weather row touched. Idempotent guards so reruns are +-- safe on prod. +-- +-- Reversibility (down): +-- ALTER TABLE "users" DROP COLUMN IF EXISTS "home_since"; +-- -- (the `DEVICE` enum value cannot be dropped in place; it is inert until a +-- -- future client writes it, so a roll-back leaves it harmless.) + +-- AlterTable: effective-from instant for the home location (nullable). +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "home_since" TIMESTAMP(3); + +-- Backfill: stamp existing home rows so they resolve from the upgrade forward. +UPDATE "users" + SET "home_since" = CURRENT_TIMESTAMP + WHERE "home_lat" IS NOT NULL AND "home_since" IS NULL; + +-- AlterEnum: reserve the DEVICE source for the iOS v2 forward path. +ALTER TYPE "environment_location_source" ADD VALUE IF NOT EXISTS 'DEVICE'; diff --git a/prisma/migrations/0220_v125_extracted_facts_encrypt/migration.sql b/prisma/migrations/0220_v125_extracted_facts_encrypt/migration.sql new file mode 100644 index 000000000..3c5a70d1e --- /dev/null +++ b/prisma/migrations/0220_v125_extracted_facts_encrypt/migration.sql @@ -0,0 +1,42 @@ +-- v1.25 (W-DOCS-IN, security) — encrypt the staged extracted-fact payloads. +-- +-- The inbound-document store already encrypts the raw upload +-- (`inbound_documents.content_encrypted`) precisely because it is sensitive +-- PHI. The per-fact staging columns `extracted_facts.data_json` / +-- `provenance_json`, however, persisted the SAME class of data in the clear: +-- the FHIR-staged clinical values (diagnosis text, lab values, medication +-- names, stated codes) and the verbatim source span (up to 2000 chars copied +-- straight out of the discharge letter / lab report). That defeated the +-- at-rest guarantee for the feature. This migration converts both columns to +-- the AES-256-GCM `*_encrypted` BYTEA convention shared with the note columns; +-- the application encrypts on write and decrypts at confirm time. +-- +-- Transactional + fail-closed: encryption is application-layer, so the plaintext +-- JSONB cannot be re-encrypted in SQL. Staged facts are EPHEMERAL pre-confirm +-- (nothing committed until the user approves them) and this feature has not +-- shipped, so any rows staged by a pre-release build carry plaintext PHI we +-- will not retain — they are dropped here rather than left unencrypted, and +-- their parent document keeps its encrypted original and can be re-extracted. +-- The whole migration runs in one transaction, so a failure rolls back wholly. +-- +-- Reversibility (down) — re-add the plaintext columns (data is not recoverable): +-- ALTER TABLE "extracted_facts" ADD COLUMN "data_json" JSONB; +-- ALTER TABLE "extracted_facts" ADD COLUMN "provenance_json" JSONB; +-- ALTER TABLE "extracted_facts" DROP COLUMN "data_encrypted"; +-- ALTER TABLE "extracted_facts" DROP COLUMN "provenance_encrypted"; + +-- Add the encrypted replacements (nullable during the swap). +ALTER TABLE "extracted_facts" ADD COLUMN IF NOT EXISTS "data_encrypted" BYTEA; +ALTER TABLE "extracted_facts" ADD COLUMN IF NOT EXISTS "provenance_encrypted" BYTEA; + +-- Drop any pre-release staged rows: their plaintext payload cannot be +-- re-encrypted in SQL, and a pre-confirm fact carries no committed data. +DELETE FROM "extracted_facts" WHERE "data_encrypted" IS NULL; + +-- Enforce the at-rest guarantee: every staged fact now carries ciphertext. +ALTER TABLE "extracted_facts" ALTER COLUMN "data_encrypted" SET NOT NULL; +ALTER TABLE "extracted_facts" ALTER COLUMN "provenance_encrypted" SET NOT NULL; + +-- Drop the plaintext columns so no verbatim clinical span survives. +ALTER TABLE "extracted_facts" DROP COLUMN IF EXISTS "data_json"; +ALTER TABLE "extracted_facts" DROP COLUMN IF EXISTS "provenance_json"; diff --git a/prisma/migrations/0221_v125_mental_health_external_id_dedup/migration.sql b/prisma/migrations/0221_v125_mental_health_external_id_dedup/migration.sql new file mode 100644 index 000000000..7630417ff --- /dev/null +++ b/prisma/migrations/0221_v125_mental_health_external_id_dedup/migration.sql @@ -0,0 +1,22 @@ +-- v1.25 — iOS parity: durable dedup for mental-health screener administrations. +-- +-- The native client queues completed check-ins in an outbox and replays them on +-- reconnect. The 24h idempotency-key window covers a fast retry, but a queued +-- entry can replay long after that window closes. Without a stable client key +-- the replay would mint a duplicate administration AND a duplicate *_SCORE trend +-- point. +-- +-- The `source` + `external_id` columns already exist (migration 0213). This adds +-- the DB-level backstop: a partial UNIQUE index over `(user_id, external_id)` +-- restricted to non-NULL externalIds. It deduplicates across sources (a repeat +-- externalId collides regardless of WEB vs IOS), while NULL externalIds (the +-- web path that supplies none) stay unconstrained by the partial predicate. +-- +-- The route pre-checks `(user_id, external_id)` and returns the existing row; +-- this index is the race backstop (a concurrent insert surfaces P2002, which +-- the route catches and resolves to the winning row). Idempotent + forward-only; +-- reversible with `DROP INDEX IF EXISTS "mental_health_assessments_user_external_id_unique"`. + +CREATE UNIQUE INDEX IF NOT EXISTS "mental_health_assessments_user_external_id_unique" + ON "mental_health_assessments" ("user_id", "external_id") + WHERE "external_id" IS NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5bbd4cb75..f10c6427f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -51,6 +51,25 @@ model User { gender String? @map("gender") // "MALE" | "FEMALE" | "OTHER" | null = keine Angabe timezone String @default("Europe/Berlin") + // v1.25 (W-ENV) — opt-in environmental-context module home location. A coarse, + // user-set city (rounded lat/lon, ~city granularity — never a GPS track) the + // nightly weather fetch resolves against when no travel override covers the + // day. City-level granularity is the privacy floor: the weather model only + // needs ~10–25 km. All nullable — an account without the module enabled (or + // without a home set) simply has no environment rows. `homeTimezone` anchors + // the day-key an observation is stored under (it can differ from the display + // `timezone` above for an account that tracks weather for another locale). + homeLat Float? @map("home_lat") + homeLon Float? @map("home_lon") + homeLabel String? @map("home_label") + homeTimezone String? @map("home_timezone") + // v1.25 — effective-from instant for the home location. Stamped the moment a + // home is set/updated. Day resolution attributes a past day to home ONLY when + // the day is on/after this instant; earlier days are skipped (never fabricated + // from the current home). To fill the deep past the user adds an explicit + // dated location period instead. Backfill defaults its start to this instant. + homeSince DateTime? @map("home_since") + // Codex OAuth (ChatGPT subscription) codexAccessTokenEncrypted String? @map("codex_access_token_encrypted") codexRefreshTokenEncrypted String? @map("codex_refresh_token_encrypted") @@ -583,6 +602,7 @@ model User { measurements Measurement[] medications Medication[] intakeEvents MedicationIntakeEvent[] + medicationPauseEras MedicationPauseEra[] apiTokens ApiToken[] // v1.24 — MCP remote OAuth 2.1 connections (1:N). Each row is a granted // authorization for an MCP client, audience-bound to health:read/health:write. @@ -607,6 +627,7 @@ model User { fitbitConnection FitbitConnection? fitbitOAuthStates FitbitOAuthState[] moodEntries MoodEntry[] + mentalHealthAssessments MentalHealthAssessment[] moodTagsCustom MoodTag[] @relation("UserMoodTags") moodTagCategoriesCustom MoodTagCategory[] @relation("UserMoodTagCategories") moodTagsHidden MoodTagHidden[] @@ -719,6 +740,25 @@ model User { // per-day timeline mirroring the CycleDayLog shape. illnessEpisodes IllnessEpisode[] illnessDayLogs IllnessDayLog[] + // v1.25 (W-RECORDS) — structured health-record entries. Allergies map to + // FHIR AllergyIntolerance; family-history entries map to FHIR + // FamilyMemberHistory. Reference records (not time-series signals); the + // sensitive free-text fields are encrypted at rest. + allergies Allergy[] + familyHistory FamilyHistoryEntry[] + // v1.25 (W-ENV) — opt-in environmental-context module. `EnvironmentContext` + // is the per-day weather/daylight observation keyed to the resolved location; + // `EnvironmentTravelLocation` holds the manual date-range travel overrides + // that win over the home location for the days they cover. + environmentContexts EnvironmentContext[] + environmentTravelLocations EnvironmentTravelLocation[] + // v1.25 (W-DOCS-IN) — inbound clinical documents (doctor reports / + // discharge letters) the user uploads. The raw document is encrypted at + // rest; extracted facts land in a review-then-confirm staging area + // (ExtractedFact) and only reach the structured stores on explicit + // approval. Opt-in module, off by default. + inboundDocuments InboundDocument[] + extractedFacts ExtractedFact[] // v1.19.0 — full Withings ECG waveform recordings (encrypted at rest). // The paired AFib verdict still lands as an IRREGULAR_RHYTHM_NOTIFICATION // Measurement EVENT row (v1.18.11); this holds the raw signal samples. @@ -774,6 +814,107 @@ model User { @@map("users") } +// ─── v1.25 (W-ENV) Environmental-context module ────────────────────────────── + +/// How the location for an environment observation was resolved. HOME = the +/// user's configured home location; TRAVEL = an explicit dated location period +/// (a past trip OR a corrected stretch of history) that overrides home for the +/// days it covers; DEVICE = a future client-supplied coarse per-day location. +/// DEVICE is the priority forward path for the iOS v2 client: the value exists +/// so the resolver and the wire contract already accept it, but nothing writes +/// it server-side yet. +enum EnvironmentLocationSource { + HOME + TRAVEL + DEVICE +} + +/// A per-day environmental observation (daily weather / daylight) for the +/// location resolved for that day. Day-keyed exactly like the rollup tier: +/// `date` is a YYYY-MM-DD string anchored to the location's IANA timezone, so a +/// late-evening reading does not mis-bucket under UTC. One row per (user, day); +/// a re-fetch upserts (the archive feed settles over a few days). All weather +/// fields are nullable so a partial feed still stores what it has. The location +/// stored here is coarse (city granularity) — never a GPS track. +model EnvironmentContext { + id String @id @default(cuid()) + userId String @map("user_id") + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + /// YYYY-MM-DD anchored to the resolved location's timezone. + date String + + /// Coarse resolved location for the day (rounded lat/lon + label + the source + /// that won precedence). + lat Float + lon Float + locationLabel String @map("location_label") + source EnvironmentLocationSource + + /// Daily temperature (°C). Min doubles as the overnight-low proxy. + tempMin Float? @map("temp_min") + tempMax Float? @map("temp_max") + tempMean Float? @map("temp_mean") + /// Daily apparent ("feels-like") mean (°C). + apparentMean Float? @map("apparent_mean") + + /// Sunshine + daylight, stored in seconds (the feed's native unit). + sunshineSec Int? @map("sunshine_sec") + daylightSec Int? @map("daylight_sec") + + /// Precipitation total (mm) over the local day. + precipSum Float? @map("precip_sum") + + /// Pressure aggregated from the hourly feed: daily mean (hPa) + the intraday + /// delta (max−min, hPa) — the derived feature for the headache/symptom overlay. + pressureMean Float? @map("pressure_mean") + pressureDelta Float? @map("pressure_delta") + + /// Relative humidity mean (%) and cloud-cover mean (%) from the hourly feed. + humidityMean Float? @map("humidity_mean") + cloudMean Float? @map("cloud_mean") + + /// WMO weather code for the day (descriptive condition lookup). + weatherCode Int? @map("weather_code") + + /// When the row was last fetched/refreshed from the upstream feed. + fetchedAt DateTime @default(now()) @map("fetched_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@unique([userId, date]) + @@index([userId, date]) + @@map("environment_contexts") +} + +/// An explicit dated LOCATION PERIOD: for any day in `[startDate, endDate]` the +/// weather fetch resolves against this location instead of the home location. +/// Declared, not inferred — it dominates the home fallback so a stretch spent +/// elsewhere is not mis-attributed to the home city. Two uses share this one +/// model: a discrete trip away from home, AND the mechanism to correct history +/// (e.g. a whole-history home city for the years before the current home became +/// effective). Coarse location only. (Stored under `source = TRAVEL`.) +model EnvironmentTravelLocation { + id String @id @default(cuid()) + userId String @map("user_id") + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + /// Inclusive YYYY-MM-DD range (anchored to the user's home timezone for the + /// day-key the override matches against). + startDate String @map("start_date") + endDate String @map("end_date") + + lat Float + lon Float + label String + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([userId, startDate, endDate]) + @@map("environment_travel_locations") +} + model Passkey { id String @id @default(cuid()) userId String @map("user_id") @@ -1106,10 +1247,96 @@ enum MeasurementType { // The 5-level → ordinal mapping is the single source of truth in // `src/lib/oura/client.ts` (RESILIENCE_LEVELS). Stored as `source = OURA`. RESILIENCE // level (ordinal 1–5) — Oura `daily_resilience.level` (limited=1 … exceptional=5). + // ── v1.25 — clinical-signals wave (manual / instrument-derived) ── + // PHQ-9 / GAD-7 total scores. Server-derived from a completed + // `MentalHealthAssessment` (one measurement row per administration, value = + // total). Unit `score`. The raw item answers live encrypted on the + // assessment row, never here — these rows carry only the numeric trend so + // the existing chart / rollup infra reads them with no item exposure. + PHQ9_SCORE // score (0–27) — PHQ-9 depression screen total. Opt-in. See MentalHealthAssessment. + GAD7_SCORE // score (0–21) — GAD-7 anxiety screen total. Opt-in. See MentalHealthAssessment. + // Maximal isometric handgrip force (best of all trials, either hand). The + // EWGSOP2 sarcopenia floor is sex-specific (men < 27 kg, women < 16 kg); the + // band is applied at display time, the column stores the raw kg. + GRIP_STRENGTH // kg — hand-dynamometer maximal grip (EWGSOP2 sarcopenia limb). + // Patient-reported pain intensity on the 0–10 Numeric Rating Scale + // (0 = no pain, 10 = worst imaginable). LOINC 72514-3. + PAIN_NRS // score (0–10) — pain Numeric Rating Scale. + // Waist circumference in cm (WHO STEPS protocol) + the ancestry-robust + // waist-to-height ratio (WHtR; keep waist < half your height ⇒ < 0.5). + WAIST_CIRCUMFERENCE // cm — waist circumference (WHO bands; ethnicity-aware at display). + WAIST_TO_HEIGHT // ratio — waist-to-height ratio (WHtR ≥ 0.5 = increased risk). @@map("measurement_type") } +/// v1.25 — validated mental-health self-assessment instruments: PHQ-9 +/// (depression) + GAD-7 (anxiety). The discriminator on +/// `MentalHealthAssessment`; extensible (WHO-5 / PSS-10 are natural future +/// siblings) without a schema change. +enum AssessmentInstrument { + PHQ9 + GAD7 + + @@map("assessment_instrument") +} + +/// v1.25 — one completed administration of a validated mental-health screener +/// (PHQ-9 / GAD-7). Opt-in, beside the existing `MoodEntry` (never replaces it). +/// The per-item answers are the most sensitive payload, so they live in a +/// single AES-256-GCM `Bytes` blob (`responsesEncrypted`) and are never indexed +/// nor logged. The derived total / band / item-9 flag are denormalised +/// (server-authoritative) for cheap history reads without decrypting. A +/// matching `PHQ9_SCORE` / `GAD7_SCORE` Measurement row is written per +/// administration (linked by `externalId = assessment:`) so the score rides +/// the existing chart / rollup infra — the trend, never the item content. +model MentalHealthAssessment { + id String @id @default(cuid()) + userId String @map("user_id") + instrument AssessmentInstrument + /// The validated translation actually presented (officially-validated wording + /// per locale, English fallback otherwise — recorded for the audit trail). + locale String + /// Instrument variant identifier (e.g. "standard"); future-proofs revisions. + version String @default("standard") + /// Encrypted JSON blob: { items: number[], functionalDifficulty?: 0..3, schema: 1 }. + /// AES-256-GCM via the shared Bytes codec; registered in ENCRYPTED_COLUMNS + + /// the rotation script. The item-9 raw value lives ONLY here. + responsesEncrypted Bytes @map("responses_encrypted") + /// Derived total (PHQ-9 0–27, GAD-7 0–21). Denormalised for fast reads. + totalScore Int @map("total_score") + /// Derived severity band label (server-authoritative; descriptive, never a diagnosis). + severityBand String @map("severity_band") + /// PHQ-9 only: item 9 (self-harm ideation) answered > 0. A coarse boolean so + /// the history view can re-surface crisis resources without decrypting. + item9Flagged Boolean @default(false) @map("item9_flagged") + /// Audit stamp: the supportive crisis-resource signpost was shown to the user. + /// Never triggers any third-party alert — private, on-device-only signposting. + crisisShownAt DateTime? @map("crisis_shown_at") + /// Recall-window anchor (when the screener was taken). + takenAt DateTime @map("taken_at") + tz String? + source String @default("WEB") + externalId String? @map("external_id") + syncVersion Int @default(0) @map("sync_version") + deletedAt DateTime? @map("deleted_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, source, externalId]) + @@index([userId, instrument, takenAt]) + @@index([userId, updatedAt, id]) + // `mental_health_assessments_user_external_id_unique` is a partial UNIQUE + // index over `(user_id, external_id) WHERE external_id IS NOT NULL` — see + // migration 0221. It deduplicates an outbox replay across sources (a repeat + // client externalId returns the existing administration). Prisma can't express + // a partial index in the schema language, so it lives in raw SQL and is + // intentionally omitted here. + @@map("mental_health_assessments") +} + /// v1.10.0 — categorical events (WX-B) /// The device's own classification RESULT / severity for an EVENT row. /// Non-NULL only for the categorical EVENT `MeasurementType`s @@ -1352,6 +1579,12 @@ model Measurement { // (Postgres semantics) so manual entries don't collide here. @@unique([userId, type, source, externalId]) @@index([userId, type, measuredAt]) + // v1.25 — type-less `(userId, measuredAt)` range scan. The bulk feature read + // (`extractFeatures`) and consolidation scans walk a user's measurements by + // time with no `type` predicate; `(userId, type, measuredAt)` can't serve + // that as a prefix, so without this index Postgres scans the user's whole set + // and sorts in memory. See migration 0210. + @@index([userId, measuredAt]) // v1.15.13 — leads with `(userId, source)` so the management list's // source filter + recency sort is an index range scan instead of a // `userId` scan + in-memory sort. See migration 0136. @@ -1947,6 +2180,14 @@ model Medication { /// ranges into eras so past days read against the schedule that was /// live THEN, not the current one. scheduleRevisions MedicationScheduleRevision[] + /// v1.25 — durable pause intervals. A pause stamps an open era + /// (`resumedAt = null`); resume closes the latest open era. The + /// compliance engine drops expected slots whose anchor falls inside a + /// pause interval so a resumed medication never counts the paused days + /// as missed. The `pausedAt` column on this model stays the live + /// single-pause marker the reminder/today surfaces read; the era table + /// is the additive durable record the rate paths need. + pauseEras MedicationPauseEra[] user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -2097,6 +2338,35 @@ model MedicationScheduleRevision { @@map("medication_schedule_revisions") } +/// v1.25 — durable medication pause intervals. The `Medication.pausedAt` +/// column is a single live marker that resume clears, so the paused window +/// is irrecoverable once the medication resumes. This table records each +/// pause as its own row: a pause stamps an open era (`resumedAt = null`), +/// resume closes the latest open era (`resumedAt = now`). The compliance +/// engine reads the eras and drops expected dose slots whose anchor falls +/// inside any `[pausedAt, resumedAt ?? now)` interval so a resumed +/// medication never counts the paused days as missed. +model MedicationPauseEra { + id String @id @default(cuid()) + medicationId String @map("medication_id") + userId String @map("user_id") + /// Inclusive start of the pause interval (the instant the medication + /// was paused). + pausedAt DateTime @map("paused_at") @db.Timestamptz(6) + /// Exclusive end of the pause interval (the instant the medication was + /// resumed). NULL = still paused; readers treat an open era as running + /// to `now`. + resumedAt DateTime? @map("resumed_at") @db.Timestamptz(6) + createdAt DateTime @default(now()) @map("created_at") + + medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([medicationId]) + @@index([userId]) + @@map("medication_pause_eras") +} + enum IntakeSource { WEB API @@ -2266,6 +2536,10 @@ model MedicationDoseChange { /// User-entered titration note ("stepped up after 4 weeks on 5 mg, /// tolerated 7.5 mg with mild nausea day 2"). note String? + /// v1.25 — AES-256-GCM ciphertext for `note`. The plaintext column + /// stays for read-fallback until the backfill reports zero remaining + /// rows; the plaintext drop is DEFERRED to a later release. + noteEncrypted Bytes? @map("note_encrypted") createdAt DateTime @default(now()) @map("created_at") medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) @@ -2357,6 +2631,10 @@ model MedicationInventoryItem { purchasedAt DateTime? @map("purchased_at") /// Free-text note, application-layer max 200 chars. notes String? + /// v1.25 — AES-256-GCM ciphertext for `notes`. The plaintext column + /// stays for read-fallback until the backfill reports zero remaining + /// rows; the plaintext drop is DEFERRED to a later release. + notesEncrypted Bytes? @map("notes_encrypted") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -2398,22 +2676,26 @@ model MedicationInventoryItem { /// - (userId, occurredAt DESC) — Coach snapshot aggregation read /// path; correlation across medications is per-user, not per-drug. model MedicationSideEffect { - id String @id @default(cuid()) - userId String @map("user_id") - medicationId String @map("medication_id") - occurredAt DateTime @default(now()) @map("occurred_at") - category MedicationSideEffectCategory - entry MedicationSideEffectEntry + id String @id @default(cuid()) + userId String @map("user_id") + medicationId String @map("medication_id") + occurredAt DateTime @default(now()) @map("occurred_at") + category MedicationSideEffectCategory + entry MedicationSideEffectEntry /// 1 = mild, 2 = moderate, 3 = significant, 4 = severe, 5 = very-severe. /// Constrained to [1, 5] at the application layer (Zod) and the DB /// (CHECK constraint via migration). Not an enum to keep cardinality /// trivially numeric for chart aggregation. - severity Int + severity Int /// Optional free-text note, hard-capped to 280 chars at the /// application layer. The DB column is unbounded TEXT for /// consistency with sibling "notes" columns on this schema. - notes String? - createdAt DateTime @default(now()) @map("created_at") + notes String? + /// v1.25 — AES-256-GCM ciphertext for `notes`. The plaintext column + /// stays for read-fallback until the backfill reports zero remaining + /// rows; the plaintext drop is DEFERRED to a later release. + notesEncrypted Bytes? @map("notes_encrypted") + createdAt DateTime @default(now()) @map("created_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) @@ -2553,21 +2835,21 @@ model TelegramPromptContext { // ─── API Tokens (for external ingest) ────────────────────── model ApiToken { - id String @id @default(cuid()) - userId String @map("user_id") - name String - tokenHash String @unique @map("token_hash") // SHA-256 of the token - permissions String[] @default(["medication:ingest"]) - lastUsedAt DateTime? @map("last_used_at") - expiresAt DateTime? @map("expires_at") - createdAt DateTime @default(now()) @map("created_at") - revoked Boolean @default(false) + id String @id @default(cuid()) + userId String @map("user_id") + name String + tokenHash String @unique @map("token_hash") // SHA-256 of the token + permissions String[] @default(["medication:ingest"]) + lastUsedAt DateTime? @map("last_used_at") + expiresAt DateTime? @map("expires_at") + createdAt DateTime @default(now()) @map("created_at") + revoked Boolean @default(false) // The MCP OAuth connection this access token belongs to, when it was minted // through the `/api/mcp/oauth` bridge. Null for manually-minted tokens and // native-client tokens. Lets a connection revoke kill every access row it // ever issued (H2), and lets the connector token list exclude the transient // 60-minute access rows so it does not flood (M2). - mcpConnectionId String? @map("mcp_connection_id") + mcpConnectionId String? @map("mcp_connection_id") user User @relation(fields: [userId], references: [id], onDelete: Cascade) mcpConnection McpOAuthConnection? @relation(fields: [mcpConnectionId], references: [id], onDelete: SetNull) @@ -5054,6 +5336,142 @@ model IllnessSymptomLink { @@map("illness_symptom_links") } +// ─── Structured health records (v1.25 W-RECORDS) ────────────────── +// +// Allergies + family history kept as STRUCTURED records (not time-series +// signals — they are NOT in the signal registry). Reference data the user +// curates once and edits rarely, surfaced in the Settings "Anamnese" +// (medical-history) section and exported to FHIR (AllergyIntolerance / +// FamilyMemberHistory). Patient-reported throughout — never a clinical +// diagnosis the app asserts. Sensitive free-text fields are AES-256-GCM +// encrypted at rest (`*Encrypted` Bytes columns, the shared note codec); +// the structured enum fields stay queryable plaintext. Soft-deleted. + +/// Broad allergen category for an Allergy (maps to FHIR +/// AllergyIntolerance.category — food / medication / environment / biologic). +enum AllergyCategory { + FOOD + MEDICATION + ENVIRONMENT + BIOLOGIC + OTHER + + @@map("allergy_category") +} + +/// Distinguishes an immune-mediated allergy from a non-immune intolerance +/// (FHIR AllergyIntolerance.type allergy | intolerance). +enum AllergyType { + ALLERGY + INTOLERANCE + + @@map("allergy_type") +} + +/// Worst-reaction severity (FHIR AllergyIntolerance.reaction.severity / +/// criticality). NULL = not assessed. +enum AllergySeverity { + MILD + MODERATE + SEVERE + + @@map("allergy_severity") +} + +/// Clinical status of the record (FHIR AllergyIntolerance.clinicalStatus). +enum AllergyStatus { + ACTIVE + INACTIVE + RESOLVED + + @@map("allergy_status") +} + +/// One self-recorded allergy/intolerance. `substance` is the queryable +/// human label (the AllergyIntolerance `code.text` anchor — never a +/// machine-guessed SNOMED/RxNorm code). The free-text reaction description +/// and notes are encrypted at rest. Patient-reported; the export carries a +/// "not a clinical diagnosis" guard rail. +model Allergy { + id String @id @default(cuid()) + userId String @map("user_id") + /// User-facing substance/allergen name, e.g. "Penicillin", "Peanuts". + /// Queryable plaintext label (the medication.name / illness.label + /// precedent); rides FHIR `code.text`. + substance String + category AllergyCategory @default(OTHER) + type AllergyType @default(ALLERGY) + /// Worst observed reaction severity. NULL = not assessed. + severity AllergySeverity? + status AllergyStatus @default(ACTIVE) + /// When the allergy was first noticed (UTC instant). NULL = unknown. + onsetAt DateTime? @map("onset_at") + /// AES-256-GCM ciphertext of the free-text reaction/manifestation + /// description (e.g. "anaphylaxis", "hives"). Bytes via the shared codec. + reactionEncrypted Bytes? @map("reaction_encrypted") + /// AES-256-GCM ciphertext of an optional free-text note. Bytes column. + notesEncrypted Bytes? @map("notes_encrypted") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + /// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant. + deletedAt DateTime? @map("deleted_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, deletedAt]) + @@map("allergies") +} + +/// Relationship of a family member to the user (maps to FHIR +/// FamilyMemberHistory.relationship). Lateralised grandparents let the +/// maternal/paternal line be recorded where the user knows it. +enum FamilyRelationship { + MOTHER + FATHER + SISTER + BROTHER + DAUGHTER + SON + GRANDMOTHER_MATERNAL + GRANDFATHER_MATERNAL + GRANDMOTHER_PATERNAL + GRANDFATHER_PATERNAL + AUNT + UNCLE + COUSIN + HALF_SIBLING + OTHER + + @@map("family_relationship") +} + +/// One condition recorded for one relative (a FHIR FamilyMemberHistory with +/// a single condition). `condition` is the queryable human label (the +/// FamilyMemberHistory.condition `code.text` anchor — never a machine-guessed +/// code); `ageAtOnset` is the relative's age when the condition began. The +/// optional free-text note is encrypted at rest. Patient-reported. +model FamilyHistoryEntry { + id String @id @default(cuid()) + userId String @map("user_id") + relationship FamilyRelationship + /// User-facing condition name, e.g. "Type 2 diabetes", "Breast cancer". + /// Queryable plaintext label; rides FHIR `condition.code.text`. + condition String + /// The relative's age (years) when the condition began. NULL = unknown. + ageAtOnset Int? @map("age_at_onset") + /// AES-256-GCM ciphertext of an optional free-text note. Bytes column. + notesEncrypted Bytes? @map("notes_encrypted") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + /// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant. + deletedAt DateTime? @map("deleted_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, deletedAt]) + @@map("family_history_entries") +} + /// One cached forecast per user, regenerated debounced / /// stale-while-revalidate (the InsightNarrative + v1.8.7 assessment /// cache pattern), NOT per-read. Derived — not soft-deleted. @@ -5093,3 +5511,148 @@ model CyclePrediction { @@unique([userId]) @@map("cycle_predictions") } + +// ───────────────────────────────────────────────────────────────────────── +// v1.25 (W-DOCS-IN) — inbound clinical documents. +// +// A self-hoster uploads a doctor report / discharge letter they RECEIVED. +// The raw document is stored encrypted at rest; the dedicated OCR/vision +// provider (the v1.22 document-scan rails) transcribes STRUCTURED FACTS into +// a review-then-confirm staging area. The app reproduces what the clinician +// wrote — it never interprets, never range-flags, never diagnoses, never +// links a condition to a medication. A fact only reaches the structured +// stores (labs / conditions / medications) once the user explicitly approves +// it; low-confidence facts fail closed (surfaced for manual review, never +// auto-committed). Opt-in module, off by default. +// ───────────────────────────────────────────────────────────────────────── + +/// What kind of clinical document was uploaded (free choice by the user; +/// drives no interpretation — it is a label only). +enum InboundDocumentKind { + DOCTOR_REPORT + DISCHARGE_LETTER + OTHER + + @@map("inbound_document_kind") +} + +/// Lifecycle of an uploaded document. EXTRACTING while the provider runs, +/// EXTRACTED once facts are staged for review, FAILED on a provider/parse +/// miss, CONFIRMED once the user has acted on every fact, DISCARDED on a +/// soft delete. +enum InboundDocumentStatus { + EXTRACTING + EXTRACTED + FAILED + CONFIRMED + DISCARDED + + @@map("inbound_document_status") +} + +/// The FHIR resource an extracted fact maps to (STATED status only): a +/// diagnosis → Condition, a measured value → Observation, a medication the +/// patient is/was taking → MedicationStatement. +enum ExtractedFactType { + CONDITION + OBSERVATION + MEDICATION_STATEMENT + + @@map("extracted_fact_type") +} + +/// Review state of a staged fact. PENDING until the user decides; APPROVED +/// once committed to a structured store; REJECTED when the user discards it. +enum ExtractedFactStatus { + PENDING + APPROVED + REJECTED + + @@map("extracted_fact_status") +} + +model InboundDocument { + id String @id @default(cuid()) + userId String @map("user_id") + kind InboundDocumentKind @default(OTHER) + /// Original filename as uploaded (sanitised, optional). + filename String? + /// Magic-byte-sniffed MIME type the document was stored as. + mimeType String @map("mime_type") + /// Stored byte length of the original (pre-encryption) document. + byteSize Int @map("byte_size") + /// AES-256-GCM ciphertext of the raw document bytes (base64 of the binary + /// → `encrypt()` string → UTF-8 BYTEA), the `*_encrypted` Bytes convention + /// shared with the note columns. The original is sensitive and never + /// stored in the clear or logged. + contentEncrypted Bytes @map("content_encrypted") + status InboundDocumentStatus @default(EXTRACTING) + /// Provider type that ran the extraction (e.g. "anthropic", "openai", + /// "local"). Diagnostic only. + providerType String? @map("provider_type") + /// The document's stated report/collection date (YYYY-MM-DD at UTC), as + /// transcribed — never inferred. + reportDate DateTime? @map("report_date") + /// Failure reason when status = FAILED (a short code, never the document). + errorReason String? @map("error_reason") + + facts ExtractedFact[] + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + /// Soft-delete tombstone. NULL = live; non-NULL = discarded. + deletedAt DateTime? @map("deleted_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, createdAt]) + @@index([userId, deletedAt]) + @@map("inbound_documents") +} + +model ExtractedFact { + id String @id @default(cuid()) + documentId String @map("document_id") + userId String @map("user_id") + factType ExtractedFactType @map("fact_type") + status ExtractedFactStatus @default(PENDING) + /// Overall extraction confidence 0..1 (the lowest per-field score). The + /// review screen gates approval of a low-confidence fact behind a manual + /// edit (fail-closed). + confidence Float @default(0) + /// True when the fact scored below the confidence floor and must be + /// reviewed/edited before it can be approved. Cleared once the user edits + /// the fact (the values then become user-asserted). + needsReview Boolean @default(true) @map("needs_review") + /// The FHIR-staged fields, STATED status only, shaped per `factType` + /// (Condition / Observation / MedicationStatement). Codes (SNOMED / ICD-10 + /// / LOINC / RxNorm / ATC) are present only when the document writes them. + /// Stored encrypted: the JSON payload (diagnosis text, lab values, + /// medication names, stated codes) is clinical PHI transcribed verbatim from + /// the source document, so it rides the AES-256-GCM `*_encrypted` BYTEA + /// convention shared with the note columns rather than as plaintext JSONB. + dataEncrypted Bytes @map("data_encrypted") + /// Per-field provenance: the verbatim source span the value came from, an + /// optional page index, and per-field confidence. Makes "extract what's + /// written" auditable against the original. The source span is a verbatim + /// excerpt of the clinical document (PHI), so it is stored encrypted with the + /// same codec as `dataEncrypted`. + provenanceEncrypted Bytes @map("provenance_encrypted") + /// The id of the structured-store row created when the fact was approved + /// (LabResult / IllnessEpisode / Medication), or NULL while PENDING / + /// REJECTED. + committedRecordId String? @map("committed_record_id") + /// The structured store the approved fact landed in ("labResult" / + /// "illnessEpisode" / "medication"), or NULL. + committedRecordType String? @map("committed_record_type") + + document InboundDocument @relation(fields: [documentId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([documentId]) + @@index([userId]) + @@map("extracted_facts") +} diff --git a/public/sw.js b/public/sw.js index e16e622bc..8082ed455 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.22.1"; + /* @sw-version-fallback */ "v1.25.0"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index 79e11d648..648fbb4f2 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -421,6 +421,88 @@ async function main() { ), ); + // ───── v1.25 medication free-text notes (Bytes columns) ───── + // "notesEncrypted" (MedicationSideEffect + MedicationInventoryItem) + + // "noteEncrypted" (MedicationDoseChange). Same shared-codec shape as the + // other free-text note columns. NULL on rows whose note is still in the + // legacy plaintext column (pre-backfill) — `rotateBytesColumn` skips those. + results.push( + await rotateBytesColumn( + "MedicationSideEffect", + "notesEncrypted", + prisma.medicationSideEffect, + ), + ); + results.push( + await rotateBytesColumn( + "MedicationDoseChange", + "noteEncrypted", + prisma.medicationDoseChange, + ), + ); + results.push( + await rotateBytesColumn( + "MedicationInventoryItem", + "notesEncrypted", + prisma.medicationInventoryItem, + ), + ); + + // ───── v1.25 mental-health screener item answers (Bytes column) ───── + // The PHQ-9 / GAD-7 encrypted per-item blob. Always present (NOT NULL), so + // every row rotates. + results.push( + await rotateBytesColumn( + "MentalHealthAssessment", + "responsesEncrypted", + prisma.mentalHealthAssessment, + ), + ); + // ───── v1.25 structured health records (Bytes columns) ───── + // Allergy free-text reaction + note; family-history note. Always encrypted + // on write (no legacy plaintext column), so every non-null row rotates. + results.push( + await rotateBytesColumn("Allergy", "reactionEncrypted", prisma.allergy), + ); + results.push( + await rotateBytesColumn("Allergy", "notesEncrypted", prisma.allergy), + ); + results.push( + await rotateBytesColumn( + "FamilyHistoryEntry", + "notesEncrypted", + prisma.familyHistoryEntry, + ), + ); + + // ───── v1.25 (W-DOCS-IN) inbound clinical document (Bytes column) ───── + // The raw uploaded doctor report / discharge letter (base64-of-binary → + // AES-256-GCM string → UTF-8 bytes). Always encrypted on write (NOT NULL), + // so every row rotates. + results.push( + await rotateBytesColumn( + "InboundDocument", + "contentEncrypted", + prisma.inboundDocument, + ), + ); + // The staged extracted-fact payloads: the FHIR-staged clinical values and the + // verbatim source-span provenance. Both NOT NULL, so every staged row rotates. + results.push( + await rotateBytesColumn( + "ExtractedFact", + "dataEncrypted", + prisma.extractedFact, + ), + ); + results.push( + await rotateBytesColumn( + "ExtractedFact", + "provenanceEncrypted", + prisma.extractedFact, + ), + ); + console.log("\n=== Rotation summary ==="); let totalRotated = 0; let totalErrors = 0; diff --git a/src/__tests__/i18n-english-leak-guard.test.ts b/src/__tests__/i18n-english-leak-guard.test.ts new file mode 100644 index 000000000..f71f44fa2 --- /dev/null +++ b/src/__tests__/i18n-english-leak-guard.test.ts @@ -0,0 +1,197 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +/** + * v1.25 — English-leak heuristic guard. + * + * The existing i18n guards (`i18n-locale-integrity`, `i18n-drift-guard`, + * `i18n-reverse-coverage`, `i18n-call-site-coverage`) all prove key-SHAPE + * parity and that no key is empty/TODO — but an English string copied verbatim + * into es/fr/it/pl passes every one of them. The v1.25 audit found exactly this + * class of bug (the illness journal shipped English in every locale; the + * PHQ-9/GAD-7 screener shipped English in es/fr/it/pl). + * + * This guard flags any es/fr/it/pl value that is byte-identical to the en value + * for a MULTI-WORD string — the strong signal of a forgotten translation. It is + * deliberately scoped to es/fr/it/pl (de carries many legitimate anglicisms the + * maintainer accepts) and to multi-word values (single tokens like "Note" / + * "Dose" / "BMI" are legitimately identical in several languages). + * + * Two escape hatches keep it lightweight without going silent: + * - a value-pattern rule auto-skips URLs / `mailto:` placeholders, and + * - an explicit KEY allowlist enumerates the legitimately-identical keys + * (clinical abbreviations, units, brand/product names, OAuth field labels). + * A genuinely new leak in any non-allowlisted key fails CI. + */ + +const ROOT = join(__dirname, "../.."); +const MESSAGES_DIR = join(ROOT, "messages"); +const EN_PATH = join(MESSAGES_DIR, "en.json"); + +// The audit scope: the four locales that must never echo English. German is +// intentionally excluded — it carries accepted anglicisms (e.g. "Health +// Score") that would be noise here, and is covered by its own pinned checks in +// `i18n-locale-integrity`. +const LEAK_LOCALES = ["es", "fr", "it", "pl"] as const; + +function flattenValues( + obj: unknown, + prefix: string, + out: [string, string][], +): void { + if (obj == null || typeof obj !== "object") return; + for (const [k, v] of Object.entries(obj as Record)) { + const key = prefix ? `${prefix}.${k}` : k; + if (typeof v === "string") out.push([key, v]); + else if (typeof v === "object") flattenValues(v, key, out); + } +} + +/** + * Letter-initial word tokens, with `{placeholders}` stripped first. A value + * with fewer than two such tokens (a single word, a bare number + unit like + * "30 pts", a pure format string) is not a candidate — only multi-word prose + * is a reliable English-leak signal. + */ +function letterWords(value: string): string[] { + return value.replace(/\{[^}]*\}/g, " ").match(/\p{L}[\p{L}\p{N}]*/gu) ?? []; +} + +// URLs and mailto placeholders are identical across locales by construction. +const URL_LIKE = /(https?:\/\/|mailto:)/i; + +/** + * A mask placeholder (e.g. a UUID template `xxxxxxxx-xxxx-…`) reads as + * "multi-word" only because separators split it; every token is a single + * repeated character. These are locale-independent by construction. + */ +function isMaskPlaceholder(words: string[]): boolean { + return words.length > 0 && words.every((w) => /^(.)\1*$/.test(w)); +} + +/** + * Keys whose value legitimately equals the en value in es/fr/it/pl. Grouped by + * reason. Prune an entry only when the value genuinely diverges in a locale. + */ +const LEGIT_IDENTICAL = new Set([ + // Clinical abbreviations / SI units — identical across locales. + "charts.vo2Max", + "cycle.insights.crosstab.unitGlucose", + "dashboard.metric.unit.glucose", + "dashboard.vo2Max", + "dashboard.vo2MaxShort", + "dashboard.vo2MaxUnit", + "insights.cardioFitness.chartTitle", + "insights.coach.metric.vo2_max", + "insights.derived.composite.READINESS.component.hrv", + "labs.catalog.hs-crp", + "measurements.typeHrvRmssd", + "measurements.typeVo2Max", + "settings.sections.sources.metrics.vo2Max", + // Brand / product / proper nouns. + "admin.glitchtipDsn", + "admin.integrationWebPush", + "admin.offlineGeoEnabled", + "admin.overview.snapshotOfflineGeoOff", + "admin.overview.snapshotOfflineGeoOn", + "admin.webPushVapidTitle", + "dashboard.hero.scoreLabel", + "insights.healthScore.provenance.sources.appleHealth", + "measurements.sourceAppleHealth", + "onboarding.source.more.appleHealth", + "settings.ai.ocrProviderOption.ANTHROPIC", + "settings.ai.providerChain.types.anthropic", + "settings.ai.providerChain.types.codex", + "settings.ai.providerSelect.anthropic", + "settings.moodLogUrl", + "settings.sections.export.import.appleHealth.title", + "settings.sections.sources.sourceLabels.APPLE_HEALTH", + "mood.tag.fastFood", + // Technical / config labels conventionally kept verbatim. + "admin.hostMetrics.diskBusyPercent", + "admin.overview.snapshotBuildSha", + "admin.servicesGlobal", + "admin.webPushVapidPublicKeyPlaceholder", + "settings.ai.baseUrlLabel", + "settings.ai.openai.modelCustomPlaceholder", + "settings.fitbitClientId", + "settings.fitbitClientSecret", + "settings.ouraClientId", + "settings.ouraClientSecret", + "settings.polarClientId", + "settings.polarClientSecret", + "settings.whoopClientId", + "settings.whoopClientSecret", + "settings.sections.api.title", + // Mental-health screener + crisis-resource copy is owned and translated by + // the mental-health workstream (PHQ-9/GAD-7 wording is locale-validated + // separately); the US brand names (988 / Crisis Text Line) stay verbatim. + // Listed here so this guard does not double-own those strings. + "mentalHealth.crisisResource.crisisTextLine.name", + "mentalHealth.crisisResource.euEmotionalSupport.name", + "mentalHealth.crisisResource.findahelpline.name", + "mentalHealth.crisisResource.krisenchat.name", + "mentalHealth.crisisResource.lifeline988.name", + "mentalHealth.crisisResource.nummerGegenKummer.name", + "mentalHealth.crisisResource.telefonSeelsorge.name", + "mentalHealth.items.gad7.1", + "mentalHealth.items.gad7.2", + "mentalHealth.items.gad7.3", + "mentalHealth.items.gad7.4", + "mentalHealth.items.gad7.5", + "mentalHealth.items.gad7.6", + "mentalHealth.items.gad7.7", + "mentalHealth.items.phq9.1", + "mentalHealth.items.phq9.2", + "mentalHealth.items.phq9.3", + "mentalHealth.items.phq9.4", + "mentalHealth.items.phq9.5", + "mentalHealth.items.phq9.6", + "mentalHealth.items.phq9.7", + "mentalHealth.items.phq9.8", + "mentalHealth.items.phq9.9", +]); + +describe("i18n English-leak guard", () => { + const en = JSON.parse(readFileSync(EN_PATH, "utf8")) as Record< + string, + unknown + >; + const enFlat: [string, string][] = []; + flattenValues(en, "", enFlat); + const enMap = new Map(enFlat); + + it.each(LEAK_LOCALES.map((locale) => ({ locale })))( + "$locale does not echo English for multi-word strings", + ({ locale }) => { + const data = JSON.parse( + readFileSync(join(MESSAGES_DIR, `${locale}.json`), "utf8"), + ) as Record; + const flat: [string, string][] = []; + flattenValues(data, "", flat); + + const leaks = flat + .filter(([key, value]) => { + if (LEGIT_IDENTICAL.has(key)) return false; + const enValue = enMap.get(key); + if (enValue === undefined) return false; + if (value.trim() !== enValue.trim()) return false; // translated + if (URL_LIKE.test(value)) return false; + const words = letterWords(value); + if (isMaskPlaceholder(words)) return false; + return words.length >= 2; // multi-word only + }) + .map(([key, value]) => `${key} = ${JSON.stringify(value)}`); + + expect( + leaks, + `${locale}.json carries English values identical to en.json.\n` + + `Translate them, or — if the match is genuinely legitimate (a\n` + + `clinical abbreviation, unit, brand, or proper noun) — add the key\n` + + `to LEGIT_IDENTICAL in this test:\n` + + leaks.map((s) => ` ${s}`).join("\n"), + ).toEqual([]); + }, + ); +}); diff --git a/src/__tests__/inventory-write-isolation.test.ts b/src/__tests__/inventory-write-isolation.test.ts index 4c02bc53a..6e38fb16b 100644 --- a/src/__tests__/inventory-write-isolation.test.ts +++ b/src/__tests__/inventory-write-isolation.test.ts @@ -28,7 +28,10 @@ const SKIP_DIRS = new Set(["__tests__", "node_modules", ".next", "generated"]); * Files allowed to write `medicationInventoryItem` rows: * - the consumption module (intake-driven consume / restore); * - the inventory persistence helpers (expire cron `updateMany`); - * - the two inventory CRUD routes (register / mutate / delete). + * - the two inventory CRUD routes (register / mutate / delete); + * - the v1.25 note-encryption backfill, whose update only rewrites the + * `notes` / `notesEncrypted` columns (never units / state) and so cannot + * touch the consumption stamp this guard protects. */ const ALLOWED_WRITERS = new Set( [ @@ -36,6 +39,7 @@ const ALLOWED_WRITERS = new Set( "src/lib/medications/inventory/service.ts", "src/app/api/medications/[id]/inventory/route.ts", "src/app/api/medications/[id]/inventory/[itemId]/route.ts", + "src/lib/jobs/med-notes-encryption-backfill.ts", ].map((p) => p.split("/").join(sep)), ); diff --git a/src/app/api/admin/audit-log/route.ts b/src/app/api/admin/audit-log/route.ts index 6ba1fb45a..26a0e970f 100644 --- a/src/app/api/admin/audit-log/route.ts +++ b/src/app/api/admin/audit-log/route.ts @@ -3,6 +3,7 @@ import { apiHandler, requireAdmin } from "@/lib/api-handler"; import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { redactSecrets } from "@/lib/logging/redact"; +import { getAuditLogRetentionDays } from "@/lib/jobs/audit-log-cleanup"; import { NextRequest } from "next/server"; import { z } from "zod/v4"; @@ -39,7 +40,9 @@ export const dynamic = "force-dynamic"; * - actor — substring match against `userId` OR `user.username` * - action — exact match (e.g. `auth.login.failed`) * - target — substring match against the JSON-encoded `details` field - * - since — ISO-8601 lower bound on `createdAt` + * - since — ISO-8601 lower bound on `createdAt`. Absent, the scan + * floors at the audit-log retention horizon so an + * open-ended sweep can't seqscan the table unbounded. * - until — ISO-8601 upper bound on `createdAt` * - filter — legacy shortcut: `auth` restricts to `auth.*` actions * @@ -117,15 +120,25 @@ export const GET = apiHandler(async (request: NextRequest) => { ands.push({ details: { contains: parsed.target } }); } - if (parsed.since || parsed.until) { - const range: { gte?: Date; lte?: Date } = {}; - if (parsed.since) range.gte = new Date(parsed.since); - if (parsed.until) range.lte = new Date(parsed.until); - ands.push({ createdAt: range }); - } + // Bound the lower edge of the scan. `actor`/`target` are substring + // matches (seqscans) and the exact `count` below runs a second pass over + // the same predicate, so an open-ended history sweep on this admin-only, + // low-frequency surface would scan the table twice with no floor. The + // cleanup job already prunes rows past the retention horizon, so aligning + // the default floor to that same horizon bounds both passes without ever + // hiding a row the deployment still keeps. An explicit `since` overrides. + const lowerBound = parsed.since + ? new Date(parsed.since) + : new Date(Date.now() - getAuditLogRetentionDays() * 86_400_000); + const createdAt: { gte: Date; lte?: Date } = { gte: lowerBound }; + if (parsed.until) createdAt.lte = new Date(parsed.until); - const where: Record | undefined = - ands.length === 0 ? undefined : ands.length === 1 ? ands[0] : { AND: ands }; + // Keep `createdAt` a top-level sibling of the optional filter clause so a + // single substring/exact filter stays flat (no `AND` wrapper); the + // collapse below only folds the independent filter `ands` entries. + const filterWhere: Record = + ands.length === 0 ? {} : ands.length === 1 ? ands[0] : { AND: ands }; + const where: Record = { ...filterWhere, createdAt }; const [entries, total] = await Promise.all([ prisma.auditLog.findMany({ diff --git a/src/app/api/ai/test/route.ts b/src/app/api/ai/test/route.ts index aae4bbcb6..be0432a5f 100644 --- a/src/app/api/ai/test/route.ts +++ b/src/app/api/ai/test/route.ts @@ -123,10 +123,7 @@ export const POST = apiHandler(async (request: NextRequest) => { * legacy / unmapped code. Secret-free by construction. */ type TestFailureCode = - | "credentials" - | "rate_limited" - | "server_error" - | "unreachable"; + "credentials" | "rate_limited" | "server_error" | "unreachable"; /** * Map an upstream provider failure to a human-readable, secret-free diff --git a/src/app/api/allergies/[id]/route.ts b/src/app/api/allergies/[id]/route.ts new file mode 100644 index 000000000..af618247e --- /dev/null +++ b/src/app/api/allergies/[id]/route.ts @@ -0,0 +1,144 @@ +/** + * `GET /api/allergies/{id}` — one owned, live allergy record. + * `PATCH /api/allergies/{id}` — edit it (partial; field-by-field). + * `DELETE /api/allergies/{id}` — soft-delete it (idempotent; `{ deleted: true }`). + * + * Owner-scoped. `userId` is narrowed from auth and fed to the Prisma `where`; + * the body never carries it. The free-text `reaction` + `note` are encrypted + * at rest. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { + apiSuccess, + apiError, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { allergyUpdateSchema } from "@/lib/validations/allergy"; +import { toAllergyDTO } from "@/lib/records/dto"; +import type { Prisma } from "@/generated/prisma/client"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const GET = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + + const { id } = await params; + const row = await prisma.allergy.findUnique({ where: { id } }); + if (!row || row.userId !== user.id || row.deletedAt !== null) { + return apiError("Allergy not found", 404); + } + + annotate({ + action: { name: "allergy.read", entity_type: "allergy", entity_id: id }, + }); + + return apiSuccess(toAllergyDTO(row)); + }, +); + +export const PATCH = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + + const { id } = await params; + const existing = await prisma.allergy.findUnique({ + where: { id }, + select: { id: true, userId: true, deletedAt: true }, + }); + if ( + !existing || + existing.userId !== user.id || + existing.deletedAt !== null + ) { + return apiError("Allergy not found", 404); + } + + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = allergyUpdateSchema.safeParse(rawBody); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "allergy.invalid", + }); + } + const entry = parsed.data; + + // Field-by-field — only fields actually present in the body are written. + const data: Prisma.AllergyUpdateInput = {}; + if (entry.substance !== undefined) data.substance = entry.substance; + if (entry.category !== undefined) data.category = entry.category; + if (entry.type !== undefined) data.type = entry.type; + if (entry.severity !== undefined) data.severity = entry.severity; + if (entry.status !== undefined) data.status = entry.status; + if (entry.onsetAt !== undefined) { + data.onsetAt = entry.onsetAt ? new Date(entry.onsetAt) : null; + } + if (entry.reaction !== undefined) { + data.reactionEncrypted = entry.reaction + ? encryptToBytes(entry.reaction) + : null; + } + if (entry.note !== undefined) { + data.notesEncrypted = entry.note ? encryptToBytes(entry.note) : null; + } + + const updated = await prisma.allergy.update({ where: { id }, data }); + + await auditLog("allergy.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { allergyId: id }, + }); + + annotate({ + action: { name: "allergy.update", entity_type: "allergy", entity_id: id }, + }); + + return apiSuccess(toAllergyDTO(updated)); + }, +); + +export const DELETE = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + + const { id } = await params; + const existing = await prisma.allergy.findUnique({ + where: { id }, + select: { id: true, userId: true, deletedAt: true }, + }); + if (!existing || existing.userId !== user.id) { + return apiError("Allergy not found", 404); + } + + if (existing.deletedAt === null) { + await prisma.allergy.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); + await auditLog("allergy.delete", { + userId: user.id, + ipAddress: getClientIp(request), + details: { allergyId: id }, + }); + } + + annotate({ + action: { name: "allergy.delete", entity_type: "allergy", entity_id: id }, + }); + + return apiSuccess({ deleted: true }); + }, +); diff --git a/src/app/api/allergies/route.ts b/src/app/api/allergies/route.ts new file mode 100644 index 000000000..385d87dce --- /dev/null +++ b/src/app/api/allergies/route.ts @@ -0,0 +1,121 @@ +/** + * `GET /api/allergies` — newest-first list of the account's allergies. + * `POST /api/allergies` — create one allergy/intolerance record. + * + * A structured AllergyIntolerance-style RECORD, patient-reported — never a + * clinical diagnosis the app asserts. `userId` is narrowed from auth and fed + * to the Prisma `where`; it is never a body field. The `data` object is built + * field-by-field (no mass assignment); the free-text `reaction` + `note` are + * encrypted at rest. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { withIdempotency } from "@/lib/idempotency"; +import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { + allergyCreateSchema, + allergyListQuerySchema, +} from "@/lib/validations/allergy"; +import { toAllergyDTO } from "@/lib/records/dto"; + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const params = new URL(request.url).searchParams; + const parsed = allergyListQuerySchema.safeParse({ + limit: params.get("limit") ?? undefined, + includeInactive: params.get("includeInactive") ?? undefined, + }); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "allergy.invalid", + }); + } + + const limit = parsed.data.limit ?? 100; + const rows = await prisma.allergy.findMany({ + where: { + userId: user.id, + deletedAt: null, + ...(parsed.data.includeInactive === "false" ? { status: "ACTIVE" } : {}), + }, + orderBy: { createdAt: "desc" }, + take: limit, + }); + + annotate({ + action: { name: "allergy.list", entity_type: "allergy" }, + meta: { count: rows.length }, + }); + + return apiSuccess(rows.map(toAllergyDTO)); +}); + +// `withIdempotency` lets an iOS retry / double-tap re-send the same +// `Idempotency-Key` without minting a duplicate record (the labs / +// illness create precedent). +export const POST = apiHandler(withIdempotency<[NextRequest]>(postAllergy)); + +async function postAllergy(request: NextRequest): Promise { + const { user } = await requireAuth(); + + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = allergyCreateSchema.safeParse(rawBody); + if (!parsed.success) { + annotate({ + action: { name: "allergy.validation-failed" }, + meta: { issue_count: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422, { + errorCode: "allergy.invalid", + }); + } + + const entry = parsed.data; + + // Field-by-field — never spread the parsed object whole. + const created = await prisma.allergy.create({ + data: { + userId: user.id, + substance: entry.substance, + category: entry.category, + type: entry.type, + severity: entry.severity ?? null, + status: entry.status, + onsetAt: entry.onsetAt ? new Date(entry.onsetAt) : null, + reactionEncrypted: entry.reaction ? encryptToBytes(entry.reaction) : null, + notesEncrypted: entry.note ? encryptToBytes(entry.note) : null, + }, + }); + + await auditLog("allergy.create", { + userId: user.id, + ipAddress: getClientIp(request), + details: { allergyId: created.id, category: created.category }, + }); + + annotate({ + action: { + name: "allergy.create", + entity_type: "allergy", + entity_id: created.id, + }, + meta: { category: created.category, type: created.type }, + }); + + return apiSuccess(toAllergyDTO(created), 201); +} diff --git a/src/app/api/auth/check-user/route.ts b/src/app/api/auth/check-user/route.ts index c79b0a96c..56694d8e0 100644 --- a/src/app/api/auth/check-user/route.ts +++ b/src/app/api/auth/check-user/route.ts @@ -53,10 +53,7 @@ const bodySchema = z.object({ }); export type CheckUserBranch = - | "not_found" - | "passkey_only" - | "email_fallback" - | "exists"; + "not_found" | "passkey_only" | "email_fallback" | "exists"; export const POST = apiHandler(async (request: NextRequest) => { // v1.4.43 W13 M-4 — tighter shared bucket on trust-chain misconfig. diff --git a/src/app/api/auth/me/locale/route.ts b/src/app/api/auth/me/locale/route.ts new file mode 100644 index 000000000..06681f44a --- /dev/null +++ b/src/app/api/auth/me/locale/route.ts @@ -0,0 +1,62 @@ +/** + * PUT /api/auth/me/locale + * + * v1.25.0 — persist the user's UI locale onto `User.locale`. + * + * The language switcher historically wrote the choice to localStorage, + * the `healthlog-locale` cookie and `` ONLY — never to the + * user row. Server-side background work (the proactive Coach nudge, the + * Telegram test message, …) has no request cookie to read, so it falls + * back to `User.locale`; with the column left null those messages went + * out in English regardless of the language the user reads the app in. + * + * This setter closes the gap: the client persists the active locale here + * (on every switch, plus a one-time backfill on mount) so the column + * reliably mirrors the UI language and the cron paths render in it. + * + * Auth: cookie session OR Bearer token. + * Validation: `{ "locale": "de" }` — must be one of the supported + * locales; anything else is rejected at the surface so the column never + * holds a value the resolver would later have to defend against. + */ +import { NextRequest } from "next/server"; +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiError, apiSuccess, safeJson } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { locales, type Locale } from "@/lib/i18n/config"; + +export const dynamic = "force-dynamic"; + +function isLocale(value: unknown): value is Locale { + return ( + typeof value === "string" && (locales as readonly string[]).includes(value) + ); +} + +export const PUT = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + annotate({ action: { name: "user.locale.update" } }); + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 4 * 1024, + }); + if (jsonError) return jsonError; + + const locale = (body as { locale?: unknown } | null)?.locale; + if (!isLocale(locale)) { + return apiError("Not a supported locale.", 422); + } + + // Idempotent: skip the write (and the annotation noise) when the column + // already matches — the mount-time backfill fires on every page load. + if (user.locale !== locale) { + await prisma.user.update({ + where: { id: user.id }, + data: { locale }, + }); + annotate({ meta: { locale_next: locale } }); + } + + return apiSuccess({ locale }); +}); 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 46dbd045d..c67287ba2 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 @@ -102,6 +102,8 @@ describe("GET /api/auth/me/notification-prefs", () => { nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly", + ambientSuggestions: true, + nudgeAiComposed: false, }, measurementReminder: { clientManaged: false, @@ -137,6 +139,8 @@ describe("GET /api/auth/me/notification-prefs", () => { nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly", + ambientSuggestions: true, + nudgeAiComposed: false, }, measurementReminder: { clientManaged: false, @@ -174,6 +178,8 @@ describe("GET /api/auth/me/notification-prefs", () => { nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly", + ambientSuggestions: true, + nudgeAiComposed: false, }, measurementReminder: { clientManaged: false, @@ -222,6 +228,8 @@ describe("PATCH /api/auth/me/notification-prefs", () => { nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly", + ambientSuggestions: true, + nudgeAiComposed: false, }, measurementReminder: { clientManaged: false, @@ -246,6 +254,8 @@ describe("PATCH /api/auth/me/notification-prefs", () => { nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly", + ambientSuggestions: true, + nudgeAiComposed: false, }, measurementReminder: { clientManaged: false, @@ -274,6 +284,8 @@ describe("PATCH /api/auth/me/notification-prefs", () => { nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly", + ambientSuggestions: true, + nudgeAiComposed: false, }, measurementReminder: { clientManaged: false, @@ -294,6 +306,8 @@ describe("PATCH /api/auth/me/notification-prefs", () => { nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly", + ambientSuggestions: true, + nudgeAiComposed: false, }, measurementReminder: { clientManaged: false, @@ -367,6 +381,8 @@ describe("PATCH /api/auth/me/notification-prefs", () => { nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly", + ambientSuggestions: true, + nudgeAiComposed: false, }, measurementReminder: { clientManaged: false, @@ -405,6 +421,8 @@ describe("PATCH /api/auth/me/notification-prefs", () => { nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly", + ambientSuggestions: true, + nudgeAiComposed: false, }, measurementReminder: { clientManaged: false, @@ -429,6 +447,8 @@ describe("PATCH /api/auth/me/notification-prefs", () => { nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly", + ambientSuggestions: true, + nudgeAiComposed: false, }, measurementReminder: { clientManaged: false, @@ -472,6 +492,8 @@ describe("PATCH /api/auth/me/notification-prefs", () => { nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly", + ambientSuggestions: true, + nudgeAiComposed: false, }, measurementReminder: { clientManaged: false, diff --git a/src/app/api/auth/password/__tests__/route.test.ts b/src/app/api/auth/password/__tests__/route.test.ts index 5f56f677e..f5fe53763 100644 --- a/src/app/api/auth/password/__tests__/route.test.ts +++ b/src/app/api/auth/password/__tests__/route.test.ts @@ -4,6 +4,20 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; +// v1.25 — password change requires a fresh MFA step-up for enrolled accounts. +// Spread the real module so `apiHandler` keeps working; override the step-up +// guard so each test drives the enrolled / fresh-factor outcome directly. +vi.mock("@/lib/api-handler", async () => { + const actual = + await vi.importActual( + "@/lib/api-handler", + ); + return { + ...actual, + requireFreshMfaIfEnrolled: vi.fn(), + }; +}); + vi.mock("@/lib/db", () => ({ prisma: { user: { update: vi.fn() }, @@ -36,6 +50,11 @@ vi.mock("@/lib/i18n/server-locale", () => ({ resolveServerLocale: vi.fn().mockResolvedValue("en"), })); +// Fail-open breach check: a null result (corpus unreachable) never blocks. +vi.mock("@/lib/auth/hibp", () => ({ + checkPasswordBreach: vi.fn().mockResolvedValue(null), +})); + vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), @@ -50,8 +69,14 @@ vi.mock("next/headers", () => ({ })); import { POST } from "../route"; +import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { checkRateLimit } from "@/lib/rate-limit"; +import { verifyPassword, checkPasswordStrength } from "@/lib/auth/password"; +import { + requireFreshMfaIfEnrolled, + StepUpRequiredError, +} from "@/lib/api-handler"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -78,6 +103,44 @@ beforeEach(() => { vi.resetAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never); + // Default: account clears the step-up (no MFA enrolled, or a fresh factor). + vi.mocked(requireFreshMfaIfEnrolled).mockResolvedValue(SESSION_OK as never); +}); + +describe("POST /api/auth/password — MFA step-up (v1.25)", () => { + it("blocks an MFA-enrolled account without a fresh second factor", async () => { + vi.mocked(requireFreshMfaIfEnrolled).mockRejectedValue( + new StepUpRequiredError(), + ); + const res = await POST( + postReq({ + currentPassword: "old-pass-123", + newPassword: "New-Pass-456!", + }), + ); + expect(res.status).toBe(401); + // The credential is never rotated when the step-up fails. + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("proceeds when the step-up is satisfied (fresh factor / no MFA)", async () => { + // vi.resetAllMocks() wiped the factory defaults; restore the success path. + vi.mocked(verifyPassword).mockResolvedValue(true); + vi.mocked(checkPasswordStrength).mockReturnValue({ + isAcceptable: true, + feedback: [], + } as never); + const res = await POST( + postReq({ + currentPassword: "old-pass-123", + newPassword: "New-Pass-456!", + confirmPassword: "New-Pass-456!", + }), + ); + expect(res.status).toBe(200); + expect(requireFreshMfaIfEnrolled).toHaveBeenCalledWith(expect.any(Number)); + expect(prisma.user.update).toHaveBeenCalled(); + }); }); describe("POST /api/auth/password — 422 multi-issue (v1.4.43 W6)", () => { diff --git a/src/app/api/auth/password/route.ts b/src/app/api/auth/password/route.ts index 38cf11b42..3371b0a0c 100644 --- a/src/app/api/auth/password/route.ts +++ b/src/app/api/auth/password/route.ts @@ -15,7 +15,11 @@ import { safeJson, } from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiHandler, + requireFreshMfaIfEnrolled, + MFA_STEP_UP_MAX_AGE_SECONDS, +} from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { destroyAllSessions, createSession } from "@/lib/auth/session"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; @@ -23,7 +27,12 @@ import { checkPasswordBreach } from "@/lib/auth/hibp"; import { getServerTranslator } from "@/lib/i18n/server-translator"; export const POST = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + // v1.25 — for an account with a second factor active a password change + // requires a fresh step-up (within MFA_STEP_UP_MAX_AGE_SECONDS) in addition + // to the current-password re-proof below, so a hijacked live session cannot + // rotate the credential. Accounts without MFA keep the current-password-only + // contract. Mirrors the step-up on MFA-disable + recovery-code regen. + const { user } = await requireFreshMfaIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS); const rl = await checkRateLimit( `auth:password:${user.id}`, diff --git a/src/app/api/biomarkers/[id]/__tests__/route.test.ts b/src/app/api/biomarkers/[id]/__tests__/route.test.ts new file mode 100644 index 000000000..bb3f0a75d --- /dev/null +++ b/src/app/api/biomarkers/[id]/__tests__/route.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + biomarker: { findFirst: vi.fn(), update: vi.fn(), delete: vi.fn() }, + labResult: { deleteMany: vi.fn() }, + auditLog: { create: vi.fn().mockResolvedValue({}) }, + $transaction: vi.fn(async (ops: Promise[]) => Promise.all(ops)), + }, +})); + +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/labs/biomarker-store", () => ({ + encryptContextToBytes: vi.fn(() => new Uint8Array([1])), + decryptContextFromBytes: vi.fn(() => "ctx"), +})); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { PUT, DELETE } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { prisma } from "@/lib/db"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "u", role: "USER" as const, locale: "en" }, +}; + +const EXISTING = { + id: "bm-1", + userId: "user-1", + name: "LDL", + unit: "mg/dL", + lowerBound: 0, + upperBound: 100, + panel: null, + hidden: false, + contextEncrypted: null, + createdAt: new Date("2026-06-01T00:00:00.000Z"), + updatedAt: new Date("2026-06-01T00:00:00.000Z"), +}; + +const params = (id: string) => ({ params: Promise.resolve({ id }) }); + +function putReq(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/biomarkers/bm-1", { + method: "PUT", + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + }); +} + +function delReq(): NextRequest { + return new NextRequest("http://localhost/api/biomarkers/bm-1", { + method: "DELETE", + }); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); + vi.mocked(prisma.$transaction).mockImplementation((async ( + ops: Promise[], + ) => Promise.all(ops)) as never); +}); + +describe("DELETE /api/biomarkers/[id]", () => { + it("drops the marker and its readings in one userId-narrowed transaction", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.biomarker.findFirst).mockResolvedValue(EXISTING as never); + vi.mocked(prisma.labResult.deleteMany).mockResolvedValue({ + count: 3, + } as never); + vi.mocked(prisma.biomarker.delete).mockResolvedValue(EXISTING as never); + + const res = await DELETE(delReq(), params("bm-1")); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { deleted: boolean } | null }; + expect(body.data?.deleted).toBe(true); + + expect(prisma.labResult.deleteMany).toHaveBeenCalledWith({ + where: { userId: "user-1", biomarkerId: "bm-1" }, + }); + expect(prisma.biomarker.delete).toHaveBeenCalledWith({ + where: { id: "bm-1" }, + }); + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + }); + + it("404s a cross-user marker without deleting anything", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.biomarker.findFirst).mockResolvedValue({ + ...EXISTING, + userId: "other-user", + } as never); + + const res = await DELETE(delReq(), params("bm-1")); + expect(res.status).toBe(404); + expect(prisma.labResult.deleteMany).not.toHaveBeenCalled(); + expect(prisma.biomarker.delete).not.toHaveBeenCalled(); + }); +}); + +describe("PUT /api/biomarkers/[id] — target range", () => { + it("persists an updated reference window", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.biomarker.findFirst).mockResolvedValue(EXISTING as never); + vi.mocked(prisma.biomarker.update).mockResolvedValue({ + ...EXISTING, + lowerBound: 10, + upperBound: 50, + } as never); + + const res = await PUT( + putReq({ lowerBound: 10, upperBound: 50 }), + params("bm-1"), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { lowerBound: number; upperBound: number } | null; + }; + expect(body.data?.lowerBound).toBe(10); + expect(body.data?.upperBound).toBe(50); + + const updateCall = vi.mocked(prisma.biomarker.update).mock.calls[0][0] as { + data: Record; + }; + expect(updateCall.data.lowerBound).toBe(10); + expect(updateCall.data.upperBound).toBe(50); + }); + + it("422s an inverted target range without updating", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.biomarker.findFirst).mockResolvedValue(EXISTING as never); + + const res = await PUT( + putReq({ lowerBound: 80, upperBound: 20 }), + params("bm-1"), + ); + expect(res.status).toBe(422); + expect(prisma.biomarker.update).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/biomarkers/[id]/route.ts b/src/app/api/biomarkers/[id]/route.ts index 3b533241b..89dcce4c6 100644 --- a/src/app/api/biomarkers/[id]/route.ts +++ b/src/app/api/biomarkers/[id]/route.ts @@ -24,10 +24,10 @@ import { effectiveBound, isInvertedRange } from "@/lib/validations/labs"; * * PUT applies a partial edit (`data` built field-by-field; an explicit `null` * clears `context` / `panel` / a bound, an omitted key leaves it untouched). - * DELETE hard-deletes the catalog definition — the `onDelete: SetNull` FK on - * `LabResult.biomarkerId` unlinks every reading without losing it (the row - * keeps its legacy `analyte` / `unit` historical fields). Cross-user rows - * surface as 404 (existence sealed). + * DELETE hard-deletes the catalog definition together with every reading it + * owns, in one userId-narrowed transaction — removing a biomarker is a + * deliberate "drop this and its values" action. Cross-user rows surface as + * 404 (existence sealed). */ type RouteParams = { params: Promise<{ id: string }> }; @@ -187,10 +187,17 @@ export const DELETE = apiHandler( return apiError("Biomarker not found", 404); } - // Hard delete — the `onDelete: SetNull` FK unlinks every reading rather - // than cascading it away. The readings keep their legacy `analyte` / - // `unit` so the history survives the catalog edit. - await prisma.biomarker.delete({ where: { id } }); + // Hard delete the marker AND its readings in one transaction. Deleting a + // biomarker is a deliberate "remove this and its history" action, so the + // readings go with it rather than lingering as orphaned, unlinked rows. + // Both legs are userId-narrowed so a cross-user id can never reach another + // account's data. + await prisma.$transaction([ + prisma.labResult.deleteMany({ + where: { userId: user.id, biomarkerId: id }, + }), + prisma.biomarker.delete({ where: { id } }), + ]); await auditLog("biomarker.delete", { userId: user.id, diff --git a/src/app/api/dashboard/summary/__tests__/route.test.ts b/src/app/api/dashboard/summary/__tests__/route.test.ts index 0080f84b5..5160dd652 100644 --- a/src/app/api/dashboard/summary/__tests__/route.test.ts +++ b/src/app/api/dashboard/summary/__tests__/route.test.ts @@ -175,8 +175,7 @@ describe("GET /api/dashboard/summary", () => { // The previous shape emitted "Gewicht" / "kg" / "Schritte" verbatim, // which injected DE into an English iOS UI. const weightCard = body.data.metrics.find((m) => m.id === "weight") as - | { titleKey: string; unitKey: string } - | undefined; + { titleKey: string; unitKey: string } | undefined; expect(weightCard?.titleKey).toBe("dashboard.metric.title.weight"); expect(weightCard?.unitKey).toBe("dashboard.metric.unit.weight"); expect( diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts index 591eae953..15121fc65 100644 --- a/src/app/api/dashboard/widgets/route.ts +++ b/src/app/api/dashboard/widgets/route.ts @@ -29,7 +29,7 @@ import { Prisma } from "@/generated/prisma/client"; import { z } from "zod/v4"; import { invalidateUserDashboardWidgets } from "@/lib/cache/invalidate"; import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; -import { redactSensitiveFields } from "@/lib/observability/redact-payload"; +import { redactForExcerpt } from "@/lib/observability/redact-payload"; import type { NextRequest } from "next/server"; // Single source of truth — every widget id the layout accepts. The @@ -211,13 +211,11 @@ export const PUT = apiHandler(async (request: NextRequest) => { // the full body — so PII / token-like fields cannot leak. The // diagnostic shape is built by the shared `buildPayloadDiagnostic` // helper (v1.4.49) so the widget + series routes can't drift; the - // body is routed through `redactSensitiveFields` first so any + // body is routed through `redactForExcerpt` first so any // future field matching the denylist (password / token / secret / // apiKey / authorization / csrfState / nonce) lands as the literal // `"[redacted]"` instead of its raw value. - const payloadDiagnostic = buildPayloadDiagnostic( - redactSensitiveFields(body), - ); + const payloadDiagnostic = buildPayloadDiagnostic(redactForExcerpt(body)); annotate({ action: { name: "dashboard.widgets.validation-failed" }, meta: { diff --git a/src/app/api/documents/inbound/[id]/confirm/route.ts b/src/app/api/documents/inbound/[id]/confirm/route.ts new file mode 100644 index 000000000..a4fbba519 --- /dev/null +++ b/src/app/api/documents/inbound/[id]/confirm/route.ts @@ -0,0 +1,164 @@ +/** + * v1.25 (W-DOCS-IN) — review-then-confirm: commit the user's decisions. + * + * The ONLY write path out of the staging area. The user approves or rejects + * each staged fact; an approved fact is committed to its structured store + * (labs / conditions / medications) through the normal field-by-field create. + * Nothing landed in the structured stores until this call — that is the + * structural guarantee that the app never "decided" anything. + * + * Fail-closed: a fact still flagged `needsReview` (it scored below the + * confidence floor and was never edited) CANNOT be approved — it is reported + * back as `needsReview` so the user edits it first. A per-fact commit miss + * (e.g. a numeric observation with no unit) is reported per-fact and never + * fails the whole batch. `userId` is narrowed from the session; every fact id + * is re-scoped to the document + the caller. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { commitApprovedFact, FactCommitError } from "@/lib/documents/commit"; +import { annotate } from "@/lib/logging/context"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { withIdempotency } from "@/lib/idempotency"; +import { inboundConfirmSchema } from "@/lib/validations/inbound-documents"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const dynamic = "force-dynamic"; + +async function confirmDocument( + request: NextRequest, + { params }: RouteParams, +): Promise { + const { user } = await requireAuth(); + const gate = await requireModuleEnabled(user.id, "inboundDocuments"); + if (!gate.enabled) return gate.response; + + const { id } = await params; + + const document = await prisma.inboundDocument.findFirst({ + where: { id, userId: user.id, deletedAt: null }, + select: { id: true }, + }); + if (!document) { + return apiError("Document not found", 404, { + errorCode: "documents.inbound.notFound", + }); + } + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = inboundConfirmSchema.safeParse(body); + if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + + const approved: { factId: string; recordType: string; recordId: string }[] = + []; + const rejected: string[] = []; + const needsReview: string[] = []; + const failed: { factId: string; reason: string }[] = []; + + for (const decision of parsed.data.decisions) { + const fact = await prisma.extractedFact.findFirst({ + where: { + id: decision.factId, + documentId: document.id, + userId: user.id, + status: "PENDING", + }, + }); + if (!fact) { + // Already decided, or not owned/known — skip silently as a no-op. + continue; + } + + if (decision.action === "reject") { + await prisma.extractedFact.update({ + where: { id: fact.id }, + data: { status: "REJECTED" }, + }); + rejected.push(fact.id); + continue; + } + + // approve — fail closed on a low-confidence fact the user never edited. + if (fact.needsReview) { + needsReview.push(fact.id); + continue; + } + + try { + const ref = await commitApprovedFact(user.id, fact); + await prisma.extractedFact.update({ + where: { id: fact.id }, + data: { + status: "APPROVED", + committedRecordId: ref.recordId, + committedRecordType: ref.recordType, + }, + }); + approved.push({ + factId: fact.id, + recordType: ref.recordType, + recordId: ref.recordId, + }); + } catch (err) { + if (err instanceof FactCommitError) { + failed.push({ factId: fact.id, reason: err.code }); + continue; + } + throw err; + } + } + + // When every fact has been decided, the document is fully reviewed. + const remainingPending = await prisma.extractedFact.count({ + where: { documentId: document.id, status: "PENDING" }, + }); + if (remainingPending === 0) { + await prisma.inboundDocument.update({ + where: { id: document.id }, + data: { status: "CONFIRMED" }, + }); + } + + await auditLog("documents.inbound.confirm", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + documentId: document.id, + approved: approved.length, + rejected: rejected.length, + failed: failed.length, + }, + }); + + annotate({ + action: { name: "documents.inbound.confirm" }, + meta: { + documentId: document.id, + approved: approved.length, + rejected: rejected.length, + needsReview: needsReview.length, + failed: failed.length, + }, + }); + + return apiSuccess({ approved, rejected, needsReview, failed }); +} + +export const POST = apiHandler( + withIdempotency<[NextRequest, RouteParams]>(confirmDocument), +); diff --git a/src/app/api/documents/inbound/[id]/facts/[factId]/route.ts b/src/app/api/documents/inbound/[id]/facts/[factId]/route.ts new file mode 100644 index 000000000..042b1387e --- /dev/null +++ b/src/app/api/documents/inbound/[id]/facts/[factId]/route.ts @@ -0,0 +1,129 @@ +/** + * v1.25 (W-DOCS-IN) — edit a staged fact before approval. + * + * The review screen corrects OCR / units / dates / codes on a staged fact. A + * successful edit rewrites the fact's FHIR-staged payload (still STATED status + * only — the user is asserting what the document says) and clears + * `needsReview`: a low-confidence fact that was failing closed becomes + * user-asserted and is then eligible for approval. The fact's resource type + * cannot change (the edit is discriminated by `factType`). + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { prisma } from "@/lib/db"; +import { encryptFactData, serialiseFact } from "@/lib/documents/store"; +import { annotate } from "@/lib/logging/context"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { + inboundFactEditSchema, + type ConditionFactData, + type FactData, + type InboundFactEdit, + type MedicationStatementFactData, + type ObservationFactData, +} from "@/lib/validations/inbound-documents"; + +type RouteParams = { params: Promise<{ id: string; factId: string }> }; + +export const dynamic = "force-dynamic"; + +/** Build the persisted FHIR-staged payload from a validated edit. */ +function editToFactData(edit: InboundFactEdit): FactData { + if (edit.factType === "CONDITION") { + const data: ConditionFactData = { + label: edit.label, + code: edit.code ?? null, + codeSystem: edit.codeSystem ?? null, + clinicalStatus: edit.clinicalStatus ?? null, + verificationStatus: edit.verificationStatus ?? null, + onsetDate: edit.onsetDate ?? null, + }; + return data; + } + if (edit.factType === "OBSERVATION") { + const data: ObservationFactData = { + label: edit.label, + code: edit.code ?? null, + codeSystem: edit.codeSystem ?? null, + value: edit.value ?? null, + valueText: edit.valueText ?? null, + unit: edit.unit ?? null, + referenceLow: edit.referenceLow ?? null, + referenceHigh: edit.referenceHigh ?? null, + effectiveDate: edit.effectiveDate ?? null, + }; + return data; + } + const data: MedicationStatementFactData = { + name: edit.name, + dose: edit.dose ?? null, + rxNormCode: edit.rxNormCode ?? null, + atcCode: edit.atcCode ?? null, + statusStated: edit.statusStated ?? null, + effectiveDate: edit.effectiveDate ?? null, + }; + return data; +} + +export const PATCH = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const gate = await requireModuleEnabled(user.id, "inboundDocuments"); + if (!gate.enabled) return gate.response; + + const { id, factId } = await params; + + const fact = await prisma.extractedFact.findFirst({ + where: { id: factId, documentId: id, userId: user.id }, + select: { id: true, factType: true, status: true }, + }); + if (!fact) { + return apiError("Fact not found", 404, { + errorCode: "documents.inbound.factNotFound", + }); + } + if (fact.status !== "PENDING") { + return apiError("Only a pending fact can be edited", 409, { + errorCode: "documents.inbound.factNotPending", + }); + } + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = inboundFactEditSchema.safeParse(body); + if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + + // The edit cannot change the fact's resource type. + if (parsed.data.factType !== fact.factType) { + return apiError("Cannot change a fact's type", 422, { + errorCode: "documents.inbound.factTypeMismatch", + }); + } + + const updated = await prisma.extractedFact.update({ + where: { id: fact.id }, + data: { + dataEncrypted: encryptFactData(editToFactData(parsed.data)), + // The values are now user-asserted — the fail-closed gate is cleared. + needsReview: false, + }, + }); + + annotate({ + action: { name: "documents.inbound.factEdit" }, + meta: { documentId: id, factId: fact.id, factType: fact.factType }, + }); + + return apiSuccess(serialiseFact(updated)); + }, +); diff --git a/src/app/api/documents/inbound/[id]/route.ts b/src/app/api/documents/inbound/[id]/route.ts new file mode 100644 index 000000000..6add4331c --- /dev/null +++ b/src/app/api/documents/inbound/[id]/route.ts @@ -0,0 +1,83 @@ +/** + * v1.25 (W-DOCS-IN) — inbound clinical document by id: detail + discard. + * + * GET returns the document plus its staged facts for the review screen. + * DELETE soft-deletes (tombstone) and marks the document DISCARDED — facts + * already approved into the structured stores are independent rows and stay. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { serialiseDocumentDetail } from "@/lib/documents/store"; +import { annotate } from "@/lib/logging/context"; +import { requireModuleEnabled } from "@/lib/modules/gate"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler( + async (_request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const gate = await requireModuleEnabled(user.id, "inboundDocuments"); + if (!gate.enabled) return gate.response; + + const { id } = await params; + const document = await prisma.inboundDocument.findFirst({ + where: { id, userId: user.id, deletedAt: null }, + include: { facts: { orderBy: { createdAt: "asc" } } }, + }); + if (!document) { + return apiError("Document not found", 404, { + errorCode: "documents.inbound.notFound", + }); + } + + annotate({ + action: { name: "documents.inbound.get" }, + meta: { documentId: id, facts: document.facts.length }, + }); + + return apiSuccess(serialiseDocumentDetail(document, document.facts)); + }, +); + +export const DELETE = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const gate = await requireModuleEnabled(user.id, "inboundDocuments"); + if (!gate.enabled) return gate.response; + + const { id } = await params; + const document = await prisma.inboundDocument.findFirst({ + where: { id, userId: user.id, deletedAt: null }, + select: { id: true }, + }); + if (!document) { + return apiError("Document not found", 404, { + errorCode: "documents.inbound.notFound", + }); + } + + await prisma.inboundDocument.update({ + where: { id: document.id }, + data: { deletedAt: new Date(), status: "DISCARDED" }, + }); + + await auditLog("documents.inbound.discard", { + userId: user.id, + ipAddress: getClientIp(request), + details: { documentId: document.id }, + }); + + annotate({ + action: { name: "documents.inbound.discard" }, + meta: { documentId: document.id }, + }); + + return apiSuccess({ id: document.id, discarded: true }); + }, +); diff --git a/src/app/api/documents/inbound/route.ts b/src/app/api/documents/inbound/route.ts new file mode 100644 index 000000000..4b8fe1fc6 --- /dev/null +++ b/src/app/api/documents/inbound/route.ts @@ -0,0 +1,478 @@ +/** + * v1.25 (W-DOCS-IN) — inbound clinical documents: upload + list. + * + * POST ingests an uploaded doctor report / discharge letter through the + * EXISTING dedicated OCR/vision provider rails (the v1.22 document-scan + * provider), stores the raw document ENCRYPTED at rest, and stages the + * extracted STRUCTURED FACTS for a mandatory review-then-confirm screen. + * Nothing reaches the structured stores here — the confirm route is the only + * write path, and a low-confidence fact fails closed. + * + * Two modes, dispatched on the request content-type (mirroring the Lab-OCR + * extract route): + * - VISION (multipart/form-data): a photo / PDF run through the vision-capable + * provider; the original is stored encrypted. + * - TEXT (application/json, opt-in local OCR): the browser OCR's the image + * and POSTs only the extracted text; the text is stored encrypted (the raw + * image never reaches the server). + * + * Guards mirror the Lab-OCR discipline: requireAuth → module gate → resolve + * provider → assertConsentForChain → rate-limit → reserveBudget → extract → + * reconcile budget. The document is UNTRUSTED (prompt-injection): the server + * never acts on an instruction inside it — the review step is the safety + * boundary, and "extract, never interpret" is enforced end-to-end. + */ +import { Buffer } from "node:buffer"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + getClientIp, + safeJson, +} from "@/lib/api-response"; +import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; +import { assertConsentForChain } from "@/lib/ai/consent-guard"; +import { + buildDateKey, + reconcileSpend, + reserveBudget, + resolveDailyCap, +} from "@/lib/ai/coach/budget"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { + InboundExtractError, + runInboundExtraction, + type StagedFactInput, +} from "@/lib/documents/extract"; +import { + encryptDocumentToBytes, + encryptFactData, + encryptFactProvenance, + serialiseDocument, +} from "@/lib/documents/store"; +import { + resolveTextProvider, + resolveVisionProvider, +} from "@/lib/labs/ocr-capability"; +import { + BodyTooLargeError, + detectOcrMimeType, + OCR_MAX_BYTES, + readBoundedBody, +} from "@/lib/labs/ocr-upload"; +import { annotate } from "@/lib/logging/context"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { + inboundTextExtractSchema, + INBOUND_DOCUMENT_KINDS, + type InboundDocumentKindValue, +} from "@/lib/validations/inbound-documents"; + +export const dynamic = "force-dynamic"; + +/** Inbound extractions are expensive (vision) / metered (text). 6/hour. */ +const EXTRACT_LIMIT_PER_HOUR = 6; +const EXTRACT_WINDOW_MS = 60 * 60 * 1000; +const TEXT_BODY_MAX_BYTES = 512 * 1024; + +/** Persist the document + its staged facts and return the list DTO. */ +async function persistExtraction(args: { + userId: string; + kind: InboundDocumentKindValue; + filename: string | null; + mimeType: string; + byteSize: number; + contentEncrypted: Uint8Array; + providerType: string; + reportDate: string | null; + facts: StagedFactInput[]; +}) { + const document = await prisma.inboundDocument.create({ + data: { + userId: args.userId, + kind: args.kind, + filename: args.filename, + mimeType: args.mimeType, + byteSize: args.byteSize, + contentEncrypted: args.contentEncrypted, + status: "EXTRACTED", + providerType: args.providerType, + reportDate: args.reportDate + ? new Date(`${args.reportDate}T00:00:00.000Z`) + : null, + facts: { + create: args.facts.map((f) => ({ + userId: args.userId, + factType: f.factType, + status: "PENDING" as const, + confidence: f.confidence, + needsReview: f.needsReview, + dataEncrypted: encryptFactData(f.data), + provenanceEncrypted: encryptFactProvenance(f.provenance), + })), + }, + }, + include: { facts: true }, + }); + return document; +} + +export const POST = apiHandler(async (request: Request) => { + const { user } = await requireAuth(); + + // Opt-in module gate — even a valid Bearer token is refused when the surface + // is off (it ships dark; the user turns it on deliberately). + const gate = await requireModuleEnabled(user.id, "inboundDocuments"); + if (!gate.enabled) return gate.response; + + const contentType = request.headers.get("content-type") ?? ""; + if (contentType.includes("application/json")) { + return handleTextUpload(request, user.id); + } + return handleVisionUpload(request, user.id); +}); + +/** TEXT mode — structure in-browser-OCR'd text; store the text encrypted. */ +async function handleTextUpload( + request: Request, + userId: string, +): Promise { + const row = await prisma.user.findUnique({ + where: { id: userId }, + select: { labsLocalOcrEnabled: true }, + }); + if (!row?.labsLocalOcrEnabled) { + annotate({ action: { name: "documents.inbound.providerUnsupported" } }); + return apiError("Local OCR is not enabled", 422, { + errorCode: "documents.inbound.localOcrDisabled", + }); + } + + const { chain, pick } = await resolveTextProvider(userId); + if (!pick) { + return apiError("No AI provider is configured", 422, { + errorCode: "documents.inbound.providerUnsupported", + }); + } + + await assertConsentForChain({ userId, chain, surface: "insights" }); + + const rl = await checkRateLimit( + `documents-inbound:${userId}`, + EXTRACT_LIMIT_PER_HOUR, + EXTRACT_WINDOW_MS, + ); + if (!rl.allowed) { + const response = apiError("Too many uploads. Try again later.", 429, { + errorCode: "documents.inbound.rateLimited", + }); + for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { + response.headers.set(k, v); + } + return response; + } + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: TEXT_BODY_MAX_BYTES, + }); + if (jsonError) return jsonError; + + const parsed = inboundTextExtractSchema.safeParse(body); + if (!parsed.success) { + return apiError("Invalid document text payload", 422, { + errorCode: "documents.inbound.extractFailed", + }); + } + + const dateKey = buildDateKey(); + const reservation = await reserveBudget( + userId, + AI_BUDGETS.ocrExtractText.maxTokens, + dateKey, + resolveDailyCap([{ providerType: pick.entry.providerType }]), + ); + if (!reservation.allowed) { + return apiError("Your AI usage budget for today is reached.", 429, { + errorCode: "documents.inbound.budgetExceeded", + }); + } + + try { + const result = await runInboundExtraction({ + provider: pick.entry.instance, + providerType: pick.providerType, + ocrText: parsed.data.text, + }); + await reconcileSpend( + userId, + reservation.reserved, + reservation.reserved, + dateKey, + ); + + const textBytes = Buffer.from(parsed.data.text, "utf8"); + const document = await persistExtraction({ + userId, + kind: parsed.data.kind ?? result.kind, + filename: null, + mimeType: "text/plain", + byteSize: textBytes.byteLength, + contentEncrypted: encryptDocumentToBytes(textBytes), + providerType: result.providerType, + reportDate: result.reportDate, + facts: result.facts, + }); + + await auditLog("documents.inbound.upload", { + userId, + ipAddress: getClientIp(request), + details: { + documentId: document.id, + facts: document.facts.length, + mode: "text", + }, + }); + + return apiSuccess( + serialiseDocument(document, { + factCount: document.facts.length, + pendingCount: document.facts.length, + }), + 201, + ); + } catch (err) { + await reconcileSpend(userId, reservation.reserved, 0, dateKey); + if (err instanceof InboundExtractError) { + return apiError("Couldn't read the document. Try a clearer copy.", 422, { + errorCode: "documents.inbound.extractFailed", + }); + } + annotate({ + action: { name: "documents.inbound.extractFailed" }, + meta: { reason: "provider_error", mode: "text" }, + }); + return apiError("Couldn't read the document. Try a clearer copy.", 502, { + errorCode: "documents.inbound.extractFailed", + }); + } +} + +/** VISION mode — run a photo / PDF through the vision provider; store it. */ +async function handleVisionUpload( + request: Request, + userId: string, +): Promise { + const { chain, pick } = await resolveVisionProvider(userId); + if (!pick) { + return apiError("No vision-capable AI provider is configured", 422, { + errorCode: "documents.inbound.providerUnsupported", + }); + } + + await assertConsentForChain({ userId, chain, surface: "insights" }); + + const rl = await checkRateLimit( + `documents-inbound:${userId}`, + EXTRACT_LIMIT_PER_HOUR, + EXTRACT_WINDOW_MS, + ); + if (!rl.allowed) { + const response = apiError("Too many uploads. Try again later.", 429, { + errorCode: "documents.inbound.rateLimited", + }); + for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { + response.headers.set(k, v); + } + return response; + } + + const dateKey = buildDateKey(); + const reservation = await reserveBudget( + userId, + AI_BUDGETS.ocrExtract.maxTokens, + dateKey, + resolveDailyCap([{ providerType: pick.entry.providerType }]), + ); + if (!reservation.allowed) { + return apiError("Your AI usage budget for today is reached.", 429, { + errorCode: "documents.inbound.budgetExceeded", + }); + } + + try { + const contentLength = Number(request.headers.get("content-length") ?? 0); + if (contentLength > OCR_MAX_BYTES) { + await reconcileSpend(userId, reservation.reserved, 0, dateKey); + return apiError("File is too large (max 12 MB).", 413, { + errorCode: "documents.inbound.fileTooLarge", + }); + } + + let formData: FormData; + try { + const bytes = await readBoundedBody(request.body, OCR_MAX_BYTES); + formData = await new Response(new Blob([bytes]), { + headers: { "content-type": request.headers.get("content-type") ?? "" }, + }).formData(); + } catch (err) { + await reconcileSpend(userId, reservation.reserved, 0, dateKey); + if (err instanceof BodyTooLargeError) { + return apiError("File is too large (max 12 MB).", 413, { + errorCode: "documents.inbound.fileTooLarge", + }); + } + return apiError("Invalid multipart body", 400); + } + + const file = formData.get("file"); + if (!(file instanceof File)) { + await reconcileSpend(userId, reservation.reserved, 0, dateKey); + return apiError("Field 'file' must be a file", 422); + } + + // Optional document-kind label (a label only; drives no interpretation). + const kindRaw = formData.get("kind"); + const kind: InboundDocumentKindValue = + typeof kindRaw === "string" && + (INBOUND_DOCUMENT_KINDS as readonly string[]).includes(kindRaw) + ? (kindRaw as InboundDocumentKindValue) + : "OTHER"; + + let buffer: Buffer; + try { + buffer = Buffer.from(await file.arrayBuffer()); + } catch { + await reconcileSpend(userId, reservation.reserved, 0, dateKey); + return apiError("Failed to read uploaded file", 400); + } + + const mime = detectOcrMimeType(buffer); + if (!mime) { + await reconcileSpend(userId, reservation.reserved, 0, dateKey); + return apiError("Upload a JPEG, PNG, WebP, or PDF.", 415, { + errorCode: "documents.inbound.fileType", + }); + } + + if (mime === "application/pdf" && !pick.pdfSupported) { + await reconcileSpend(userId, reservation.reserved, 0, dateKey); + return apiError( + "PDF scanning needs a Claude vision provider; upload a photo instead.", + 422, + { errorCode: "documents.inbound.pdfNeedsAnthropic" }, + ); + } + + const dataBase64 = buffer.toString("base64"); + const images = + mime === "application/pdf" ? [] : [{ mediaType: mime, dataBase64 }]; + const documents = + mime === "application/pdf" + ? [{ mediaType: "application/pdf" as const, dataBase64 }] + : []; + + try { + const result = await runInboundExtraction({ + provider: pick.entry.instance, + providerType: pick.providerType, + images, + documents, + }); + await reconcileSpend( + userId, + reservation.reserved, + reservation.reserved, + dateKey, + ); + + const document = await persistExtraction({ + userId, + kind, + filename: + typeof file.name === "string" ? file.name.slice(0, 255) : null, + mimeType: mime, + byteSize: buffer.byteLength, + contentEncrypted: encryptDocumentToBytes(buffer), + providerType: result.providerType, + reportDate: result.reportDate, + facts: result.facts, + }); + + await auditLog("documents.inbound.upload", { + userId, + ipAddress: getClientIp(request), + details: { + documentId: document.id, + facts: document.facts.length, + mode: "vision", + }, + }); + + return apiSuccess( + serialiseDocument(document, { + factCount: document.facts.length, + pendingCount: document.facts.length, + }), + 201, + ); + } catch (err) { + await reconcileSpend(userId, reservation.reserved, 0, dateKey); + if (err instanceof InboundExtractError) { + return apiError( + "Couldn't read the document. Try a clearer copy.", + 422, + { + errorCode: "documents.inbound.extractFailed", + }, + ); + } + annotate({ + action: { name: "documents.inbound.extractFailed" }, + meta: { reason: "provider_error", mode: "vision" }, + }); + return apiError("Couldn't read the document. Try a clearer copy.", 502, { + errorCode: "documents.inbound.extractFailed", + }); + } + } catch (err) { + await reconcileSpend(userId, reservation.reserved, 0, dateKey).catch( + () => {}, + ); + throw err; + } +} + +/** GET — list the caller's inbound documents (newest first, live only). */ +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + + const gate = await requireModuleEnabled(user.id, "inboundDocuments"); + if (!gate.enabled) return gate.response; + + const documents = await prisma.inboundDocument.findMany({ + where: { userId: user.id, deletedAt: null }, + orderBy: { createdAt: "desc" }, + include: { + _count: { select: { facts: true } }, + facts: { where: { status: "PENDING" }, select: { id: true } }, + }, + take: 200, + }); + + annotate({ + action: { name: "documents.inbound.list" }, + meta: { count: documents.length }, + }); + + return apiSuccess({ + documents: documents.map((doc) => + serialiseDocument(doc, { + factCount: doc._count.facts, + pendingCount: doc.facts.length, + }), + ), + }); +}); diff --git a/src/app/api/environment/backfill/route.ts b/src/app/api/environment/backfill/route.ts new file mode 100644 index 000000000..1a02e93aa --- /dev/null +++ b/src/app/api/environment/backfill/route.ts @@ -0,0 +1,109 @@ +/** + * `POST /api/environment/backfill` — fetch + store past days on demand. + * + * Enqueues a per-user fetch over the requested [startDate, endDate] so the user + * can populate history rather than waiting for the rolling nightly lookback. + * The span is capped (the worker re-checks the cap too). Requires a home + * location (otherwise there is nothing to resolve). Module-gated + rate-limited; + * `userId` is narrowed from auth. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; +import { prisma } from "@/lib/db"; +import { environmentBackfillSchema } from "@/lib/validations/environment"; +import { + ENVIRONMENT_MAX_BACKFILL_DAYS, + defaultBackfillRange, + utcDayKey, +} from "@/lib/environment/service"; +import { enqueueEnvironmentFetch } from "@/lib/jobs/environment-fetch"; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const gate = await requireModuleEnabled(user.id, "environment"); + if (!gate.enabled) return gate.response; + + const rl = await checkAnalyticsReadRateLimit(user.id); + if (!rl.allowed) { + return apiError("Too many requests. Please retry shortly.", 429); + } + + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 4 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = environmentBackfillSchema.safeParse(rawBody); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "environment.invalid", + }); + } + + const profile = await prisma.user.findUnique({ + where: { id: user.id }, + select: { homeLat: true, homeSince: true }, + }); + if (profile?.homeLat == null) { + return apiError("Set a home location before backfilling.", 409, { + errorCode: "environment.no_home", + }); + } + + // Resolve the effective span. An omitted bound defaults to the conservative + // [homeSince .. today] range — never a fixed reach into the past onto the + // current home. Days before homeSince still resolve to SKIP in the worker, so + // even an explicit wider start cannot fabricate weather for the pre-home past. + const today = utcDayKey(new Date()); + const fallback = defaultBackfillRange(profile.homeSince) ?? { + startDate: today, + endDate: today, + }; + const startDate = parsed.data.startDate ?? fallback.startDate; + const endDate = parsed.data.endDate ?? fallback.endDate; + if (startDate > endDate) { + return apiError("startDate must be on or before endDate", 422, { + errorCode: "environment.invalid", + }); + } + + const [sy, sm, sd] = startDate.split("-").map(Number); + const [ey, em, ed] = endDate.split("-").map(Number); + const spanDays = + Math.round( + (Date.UTC(ey, em - 1, ed) - Date.UTC(sy, sm - 1, sd)) / MS_PER_DAY, + ) + 1; + if (spanDays > ENVIRONMENT_MAX_BACKFILL_DAYS) { + return apiError( + `Backfill range too large (max ${ENVIRONMENT_MAX_BACKFILL_DAYS} days)`, + 422, + { errorCode: "environment.range_too_large" }, + ); + } + + const enqueued = await enqueueEnvironmentFetch({ + userId: user.id, + startDate, + endDate, + }); + + annotate({ + action: { name: "environment.backfill.enqueue" }, + meta: { span_days: spanDays, enqueued }, + }); + + return apiSuccess({ enqueued, startDate, endDate, spanDays }, 202); +}); diff --git a/src/app/api/environment/geocode/route.ts b/src/app/api/environment/geocode/route.ts new file mode 100644 index 000000000..2fddc7b5f --- /dev/null +++ b/src/app/api/environment/geocode/route.ts @@ -0,0 +1,50 @@ +/** + * `GET /api/environment/geocode?q=` — forward-geocode a city to coarse matches. + * + * Proxies Open-Meteo's keyless geocoding through the server (so the browser + * never calls the third-party host and the CSP stays untouched) and returns + * coarse rounded coordinates + label + timezone for the home-location picker. + * Module-gated + rate-limited; `userId` is narrowed from auth. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; +import { geocodeQuerySchema } from "@/lib/validations/environment"; +import { geocodeLocation } from "@/lib/environment/open-meteo"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const gate = await requireModuleEnabled(user.id, "environment"); + if (!gate.enabled) return gate.response; + + // Reuse the analytics-read budget: a city search is a cheap outbound call, + // but the generous bucket caps a runaway autocomplete loop. + const rl = await checkAnalyticsReadRateLimit(user.id); + if (!rl.allowed) { + return apiError("Too many requests. Please retry shortly.", 429); + } + + const params = new URL(request.url).searchParams; + const parsed = geocodeQuerySchema.safeParse({ q: params.get("q") ?? "" }); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "environment.invalid", + }); + } + + const results = await geocodeLocation(parsed.data.q); + + annotate({ + action: { name: "environment.geocode.search" }, + meta: { result_count: results.length }, + }); + + return apiSuccess({ results }); +}); diff --git a/src/app/api/environment/home/route.ts b/src/app/api/environment/home/route.ts new file mode 100644 index 000000000..ded1c12e5 --- /dev/null +++ b/src/app/api/environment/home/route.ts @@ -0,0 +1,73 @@ +/** + * `PUT /api/environment/home` — set the account's coarse home location. + * + * The picked city (rounded lat/lon + label + IANA timezone) is stored on the + * user. Coordinates are rounded to ~city granularity here as a defence-in-depth + * floor even if a client sends finer values. Setting a home enqueues a + * lookback fetch so weather appears without waiting for the nightly tick. + * Module-gated; `userId` is narrowed from auth. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { prisma } from "@/lib/db"; +import { homeLocationSchema } from "@/lib/validations/environment"; +import { roundCoarse } from "@/lib/environment/open-meteo"; +import { enqueueEnvironmentFetch } from "@/lib/jobs/environment-fetch"; + +export const PUT = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const gate = await requireModuleEnabled(user.id, "environment"); + if (!gate.enabled) return gate.response; + + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 4 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = homeLocationSchema.safeParse(rawBody); + if (!parsed.success) { + annotate({ + action: { name: "environment.home.validation-failed" }, + meta: { issue_count: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422, { + errorCode: "environment.invalid", + }); + } + + const { lat, lon, label, timezone } = parsed.data; + // Stamp the effective-from instant: from now on this home resolves days; days + // before it stay un-attributed (filled via explicit location periods). Every + // set/update re-stamps — the home is "effective from the moment it is set". + const homeSince = new Date(); + const home = { + homeLat: roundCoarse(lat), + homeLon: roundCoarse(lon), + homeLabel: label, + homeTimezone: timezone, + homeSince, + }; + + await prisma.user.update({ where: { id: user.id }, data: home }); + + // Kick a lookback refresh so recent days populate promptly. No-ops cleanly + // when no worker is bound; the nightly cron still covers it. + await enqueueEnvironmentFetch({ userId: user.id }); + + annotate({ action: { name: "environment.home.set" } }); + + return apiSuccess({ + home: { + lat: home.homeLat, + lon: home.homeLon, + label: home.homeLabel, + timezone: home.homeTimezone, + since: homeSince.toISOString(), + }, + }); +}); diff --git a/src/app/api/environment/route.ts b/src/app/api/environment/route.ts new file mode 100644 index 000000000..2d6e1ceb2 --- /dev/null +++ b/src/app/api/environment/route.ts @@ -0,0 +1,88 @@ +/** + * `GET /api/environment` — the environmental-context module overview. + * + * Returns the account's coarse home location, its travel overrides, a small + * summary of stored daily observations (count + latest day), and the upstream + * attribution string. Module-gated: a 403 `module.disabled` envelope when the + * opt-in module is off. `userId` is narrowed from auth, never a body field. + */ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { prisma } from "@/lib/db"; +import { OPEN_METEO_ATTRIBUTION } from "@/lib/environment/open-meteo"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + + const gate = await requireModuleEnabled(user.id, "environment"); + if (!gate.enabled) return gate.response; + + const [profile, travel, contextCount, latest] = await Promise.all([ + prisma.user.findUnique({ + where: { id: user.id }, + select: { + homeLat: true, + homeLon: true, + homeLabel: true, + homeTimezone: true, + homeSince: true, + timezone: true, + }, + }), + prisma.environmentTravelLocation.findMany({ + where: { userId: user.id }, + orderBy: { startDate: "desc" }, + select: { + id: true, + startDate: true, + endDate: true, + lat: true, + lon: true, + label: true, + }, + }), + prisma.environmentContext.count({ where: { userId: user.id } }), + prisma.environmentContext.findFirst({ + where: { userId: user.id }, + orderBy: { date: "desc" }, + select: { date: true, fetchedAt: true }, + }), + ]); + + const home = + profile?.homeLat != null && profile?.homeLon != null + ? { + lat: profile.homeLat, + lon: profile.homeLon, + label: profile.homeLabel, + timezone: profile.homeTimezone ?? profile.timezone, + // Effective-from instant: the settings surface uses it to label the + // home and to prefill the backfill start (conservative default range). + since: profile.homeSince?.toISOString() ?? null, + } + : null; + + annotate({ + action: { name: "environment.overview.read" }, + meta: { + has_home: home !== null, + travel_count: travel.length, + context_days: contextCount, + }, + }); + + return apiSuccess({ + home, + travel, + context: { + days: contextCount, + latestDate: latest?.date ?? null, + latestFetchedAt: latest?.fetchedAt?.toISOString() ?? null, + }, + attribution: OPEN_METEO_ATTRIBUTION, + }); +}); diff --git a/src/app/api/environment/travel/[id]/route.ts b/src/app/api/environment/travel/[id]/route.ts new file mode 100644 index 000000000..ec0bd7c04 --- /dev/null +++ b/src/app/api/environment/travel/[id]/route.ts @@ -0,0 +1,44 @@ +/** + * `DELETE /api/environment/travel/[id]` — remove a manual travel override. + * + * Owner-scoped: the delete `where` carries both the id AND `userId` so one + * account can never delete another's override. Module-gated. A missing / already + * deleted row returns 404 rather than leaking existence. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiError, apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { prisma } from "@/lib/db"; + +export const DELETE = apiHandler( + async (_request: NextRequest, ctx: { params: Promise<{ id: string }> }) => { + const { user } = await requireAuth(); + + const gate = await requireModuleEnabled(user.id, "environment"); + if (!gate.enabled) return gate.response; + + const { id } = await ctx.params; + + const result = await prisma.environmentTravelLocation.deleteMany({ + where: { id, userId: user.id }, + }); + if (result.count === 0) { + return apiError("Travel override not found", 404, { + errorCode: "environment.travel.not_found", + }); + } + + annotate({ + action: { + name: "environment.travel.delete", + entity_type: "environment_travel", + entity_id: id, + }, + }); + + return apiSuccess({ deleted: true }); + }, +); diff --git a/src/app/api/environment/travel/route.ts b/src/app/api/environment/travel/route.ts new file mode 100644 index 000000000..6f5d31f6c --- /dev/null +++ b/src/app/api/environment/travel/route.ts @@ -0,0 +1,79 @@ +/** + * `POST /api/environment/travel` — add a manual travel override. + * + * For any day in [startDate, endDate] the weather fetch resolves against this + * coarse location instead of home (a declared trip dominates the home + * fallback). Coordinates are rounded to ~city granularity. Adding an override + * enqueues a lookback refresh so its days re-resolve. Module-gated; `userId` is + * narrowed from auth. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { prisma } from "@/lib/db"; +import { travelLocationSchema } from "@/lib/validations/environment"; +import { roundCoarse } from "@/lib/environment/open-meteo"; +import { enqueueEnvironmentFetch } from "@/lib/jobs/environment-fetch"; + +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const gate = await requireModuleEnabled(user.id, "environment"); + if (!gate.enabled) return gate.response; + + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 4 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = travelLocationSchema.safeParse(rawBody); + if (!parsed.success) { + annotate({ + action: { name: "environment.travel.validation-failed" }, + meta: { issue_count: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422, { + errorCode: "environment.invalid", + }); + } + + const entry = parsed.data; + const created = await prisma.environmentTravelLocation.create({ + data: { + userId: user.id, + startDate: entry.startDate, + endDate: entry.endDate, + lat: roundCoarse(entry.lat), + lon: roundCoarse(entry.lon), + label: entry.label, + }, + select: { + id: true, + startDate: true, + endDate: true, + lat: true, + lon: true, + label: true, + }, + }); + + // Re-resolve the affected window so the override's days pick up its weather. + await enqueueEnvironmentFetch({ + userId: user.id, + startDate: entry.startDate, + endDate: entry.endDate, + }); + + annotate({ + action: { + name: "environment.travel.create", + entity_type: "environment_travel", + entity_id: created.id, + }, + }); + + return apiSuccess(created, 201); +}); diff --git a/src/app/api/export/health-record/__tests__/route.test.ts b/src/app/api/export/health-record/__tests__/route.test.ts index 67a9ba6eb..08ad8e15b 100644 --- a/src/app/api/export/health-record/__tests__/route.test.ts +++ b/src/app/api/export/health-record/__tests__/route.test.ts @@ -19,6 +19,8 @@ vi.mock("@/lib/db", () => ({ moodEntry: { findMany: vi.fn() }, labResult: { findMany: vi.fn() }, illnessEpisode: { findMany: vi.fn() }, + allergy: { findMany: vi.fn() }, + familyHistoryEntry: { findMany: vi.fn() }, user: { findUnique: vi.fn(), update: vi.fn() }, }, })); @@ -100,6 +102,8 @@ beforeEach(() => { vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never); vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.allergy.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.familyHistoryEntry.findMany).mockResolvedValue([] as never); // First findUnique = aggregator profile select; second = route KVNR select. vi.mocked(prisma.user.findUnique).mockResolvedValue({ username: "sample", diff --git a/src/app/api/export/health-record/route.ts b/src/app/api/export/health-record/route.ts index f0cea3ba2..5c81f9372 100644 --- a/src/app/api/export/health-record/route.ts +++ b/src/app/api/export/health-record/route.ts @@ -34,6 +34,7 @@ import { checkRateLimit } from "@/lib/rate-limit"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { prisma } from "@/lib/db"; import { decrypt } from "@/lib/crypto"; +import { toAllergyDTO, toFamilyHistoryEntryDTO } from "@/lib/records/dto"; import { collectDoctorReportData, normaliseDateRange, @@ -112,14 +113,32 @@ export const POST = apiHandler(async (request: NextRequest) => { selection.germanAtc ?? (GERMAN_ATC_DEFAULT_LOCALES as readonly string[]).includes(locale); - 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 }, - }), - ]); + const [data, userTz, userRow, allergyRows, familyHistoryRows] = + await Promise.all([ + collectDoctorReportData(user.id, range, { practiceName, sections }), + resolveUserTimezone(user.id), + prisma.user.findUnique({ + where: { id: user.id }, + select: { insuranceNumberEncrypted: true, insightsCachedText: true }, + }), + // v1.25 (W-RECORDS) — structured records are always-available reference + // data (not time-windowed), so they ride alongside the report rather + // than through `collectDoctorReportData`. Owner-scoped, live rows only. + prisma.allergy.findMany({ + where: { userId: user.id, deletedAt: null }, + orderBy: { createdAt: "desc" }, + }), + prisma.familyHistoryEntry.findMany({ + where: { userId: user.id, deletedAt: null }, + orderBy: { createdAt: "desc" }, + }), + ]); + + // FHIR records folded into every bundle format (fhir + package). + const fhirRecords = { + allergies: allergyRows.map(toAllergyDTO), + familyHistory: familyHistoryRows.map(toFamilyHistoryEntryDTO), + }; // Decrypt the KVNR fail-soft: a key-rotation gap on one row should // never 500 the export — the cover/identifier just omits the value. @@ -163,6 +182,7 @@ export const POST = apiHandler(async (request: NextRequest) => { { insuranceNumber }, undefined, { germanAtc }, + fhirRecords, ); const json = JSON.stringify(bundle); annotate({ @@ -205,9 +225,13 @@ export const POST = apiHandler(async (request: NextRequest) => { } // format === "package": one zip holding the PDF + FHIR Bundle + a README. - const bundle = buildFhirDocumentBundle(data, { insuranceNumber }, undefined, { - germanAtc, - }); + const bundle = buildFhirDocumentBundle( + data, + { insuranceNumber }, + undefined, + { germanAtc }, + fhirRecords, + ); const readme = t("doctorReport.packageReadme"); const zipped = zipSync( { diff --git a/src/app/api/family-history/[id]/route.ts b/src/app/api/family-history/[id]/route.ts new file mode 100644 index 000000000..80d02ea8a --- /dev/null +++ b/src/app/api/family-history/[id]/route.ts @@ -0,0 +1,151 @@ +/** + * `GET /api/family-history/{id}` — one owned, live entry. + * `PATCH /api/family-history/{id}` — edit it (partial; field-by-field). + * `DELETE /api/family-history/{id}` — soft-delete it (idempotent; + * returns `{ deleted: true }`). + * + * Owner-scoped. `userId` is narrowed from auth and fed to the Prisma `where`; + * the body never carries it. The free-text `note` is encrypted at rest. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { + apiSuccess, + apiError, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { familyHistoryUpdateSchema } from "@/lib/validations/family-history"; +import { toFamilyHistoryEntryDTO } from "@/lib/records/dto"; +import type { Prisma } from "@/generated/prisma/client"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const GET = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + + const { id } = await params; + const row = await prisma.familyHistoryEntry.findUnique({ where: { id } }); + if (!row || row.userId !== user.id || row.deletedAt !== null) { + return apiError("Family history entry not found", 404); + } + + annotate({ + action: { + name: "family-history.read", + entity_type: "family_history", + entity_id: id, + }, + }); + + return apiSuccess(toFamilyHistoryEntryDTO(row)); + }, +); + +export const PATCH = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + + const { id } = await params; + const existing = await prisma.familyHistoryEntry.findUnique({ + where: { id }, + select: { id: true, userId: true, deletedAt: true }, + }); + if ( + !existing || + existing.userId !== user.id || + existing.deletedAt !== null + ) { + return apiError("Family history entry not found", 404); + } + + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = familyHistoryUpdateSchema.safeParse(rawBody); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "familyHistory.invalid", + }); + } + const entry = parsed.data; + + // Field-by-field — only fields actually present in the body are written. + const data: Prisma.FamilyHistoryEntryUpdateInput = {}; + if (entry.relationship !== undefined) { + data.relationship = entry.relationship; + } + if (entry.condition !== undefined) data.condition = entry.condition; + if (entry.ageAtOnset !== undefined) data.ageAtOnset = entry.ageAtOnset; + if (entry.note !== undefined) { + data.notesEncrypted = entry.note ? encryptToBytes(entry.note) : null; + } + + const updated = await prisma.familyHistoryEntry.update({ + where: { id }, + data, + }); + + await auditLog("family-history.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { entryId: id }, + }); + + annotate({ + action: { + name: "family-history.update", + entity_type: "family_history", + entity_id: id, + }, + }); + + return apiSuccess(toFamilyHistoryEntryDTO(updated)); + }, +); + +export const DELETE = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + + const { id } = await params; + const existing = await prisma.familyHistoryEntry.findUnique({ + where: { id }, + select: { id: true, userId: true, deletedAt: true }, + }); + if (!existing || existing.userId !== user.id) { + return apiError("Family history entry not found", 404); + } + + if (existing.deletedAt === null) { + await prisma.familyHistoryEntry.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); + await auditLog("family-history.delete", { + userId: user.id, + ipAddress: getClientIp(request), + details: { entryId: id }, + }); + } + + annotate({ + action: { + name: "family-history.delete", + entity_type: "family_history", + entity_id: id, + }, + }); + + return apiSuccess({ deleted: true }); + }, +); diff --git a/src/app/api/family-history/route.ts b/src/app/api/family-history/route.ts new file mode 100644 index 000000000..e005d523e --- /dev/null +++ b/src/app/api/family-history/route.ts @@ -0,0 +1,112 @@ +/** + * `GET /api/family-history` — newest-first list of the account's family-history + * entries. + * `POST /api/family-history` — create one condition-by-relative record. + * + * A structured FamilyMemberHistory-style RECORD, patient-reported — never a + * clinical diagnosis the app asserts. `userId` is narrowed from auth and fed + * to the Prisma `where`; it is never a body field. The `data` object is built + * field-by-field (no mass assignment); the free-text `note` is encrypted at + * rest. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { withIdempotency } from "@/lib/idempotency"; +import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { + familyHistoryCreateSchema, + familyHistoryListQuerySchema, +} from "@/lib/validations/family-history"; +import { toFamilyHistoryEntryDTO } from "@/lib/records/dto"; + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const params = new URL(request.url).searchParams; + const parsed = familyHistoryListQuerySchema.safeParse({ + limit: params.get("limit") ?? undefined, + }); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "familyHistory.invalid", + }); + } + + const limit = parsed.data.limit ?? 100; + const rows = await prisma.familyHistoryEntry.findMany({ + where: { userId: user.id, deletedAt: null }, + orderBy: { createdAt: "desc" }, + take: limit, + }); + + annotate({ + action: { name: "family-history.list", entity_type: "family_history" }, + meta: { count: rows.length }, + }); + + return apiSuccess(rows.map(toFamilyHistoryEntryDTO)); +}); + +export const POST = apiHandler( + withIdempotency<[NextRequest]>(postFamilyHistory), +); + +async function postFamilyHistory(request: NextRequest): Promise { + const { user } = await requireAuth(); + + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = familyHistoryCreateSchema.safeParse(rawBody); + if (!parsed.success) { + annotate({ + action: { name: "family-history.validation-failed" }, + meta: { issue_count: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422, { + errorCode: "familyHistory.invalid", + }); + } + + const entry = parsed.data; + + // Field-by-field — never spread the parsed object whole. + const created = await prisma.familyHistoryEntry.create({ + data: { + userId: user.id, + relationship: entry.relationship, + condition: entry.condition, + ageAtOnset: entry.ageAtOnset ?? null, + notesEncrypted: entry.note ? encryptToBytes(entry.note) : null, + }, + }); + + await auditLog("family-history.create", { + userId: user.id, + ipAddress: getClientIp(request), + details: { entryId: created.id, relationship: created.relationship }, + }); + + annotate({ + action: { + name: "family-history.create", + entity_type: "family_history", + entity_id: created.id, + }, + meta: { relationship: created.relationship }, + }); + + return apiSuccess(toFamilyHistoryEntryDTO(created), 201); +} diff --git a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts index 939ff22f8..1aa7bbe5b 100644 --- a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts +++ b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts @@ -35,6 +35,9 @@ import { describe, expect, it } from "vitest"; const NON_COACH_GATED_ROUTES: ReadonlyArray = [ // Non-Coach assistant surfaces — gated on a sibling sub-flag. + // Per-biomarker assessment. Gated on the same `insightStatus` sub-flag as + // the metric-status + specialised status routes (no Coach prose). + "src/app/api/insights/biomarker-assessment/route.ts", "src/app/api/insights/blood-pressure-status/route.ts", "src/app/api/insights/bmi-status/route.ts", "src/app/api/insights/cards/route.ts", @@ -92,6 +95,14 @@ const NOT_COACH_OWNED_ROUTES: ReadonlyArray = [ // wedge the user's ability to reorder the insights tile strip. "src/app/api/insights/layout/route.ts", "src/app/api/insights/targets/route.ts", + // v1.25 — read-only awareness cards for the overview. Each is pure compute + // over the rollup / lab tier (baseline-drift, sleep-breathing screening, + // last-panel lab deltas) and carries no assistant prose; they gate on the + // `insights` module, not on the Coach surface. Disabling Coach must not + // wedge these factual reads. + "src/app/api/insights/health-status/route.ts", + "src/app/api/insights/breathing-screening/route.ts", + "src/app/api/insights/labs-changes/route.ts", ]; const COACH_GATE_NEEDLE = 'requireAssistantSurface("coach")'; diff --git a/src/app/api/insights/biomarker-assessment/__tests__/route.test.ts b/src/app/api/insights/biomarker-assessment/__tests__/route.test.ts new file mode 100644 index 000000000..49f419399 --- /dev/null +++ b/src/app/api/insights/biomarker-assessment/__tests__/route.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + appSettings: { findUnique: vi.fn().mockResolvedValue(null) }, + }, +})); + +// The route resolves the `insights` module gate after `requireAuth()`. Mock +// it default-enabled so the assertions ride through; the off → 403 coverage +// lives in the route-gate inventory test. +vi.mock("@/lib/modules/gate", async (importOriginal) => ({ + ...(await importOriginal()), + requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), + resolveModuleMap: vi.fn().mockResolvedValue({}), +})); + +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("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +// Stub the heavy generator so the route test stays isolated from the real +// status pipeline. +vi.mock("@/lib/insights/biomarker-status", () => ({ + generateBiomarkerStatus: vi.fn(async () => ({ + hasProvider: true, + text: "ok", + cached: true, + updatedAt: new Date().toISOString(), + })), +})); + +vi.mock("@/lib/insights/metric-status", () => ({ + resolveMetricStatusLocale: () => "en", +})); + +import { GET } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { prisma } from "@/lib/db"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { generateBiomarkerStatus } from "@/lib/insights/biomarker-status"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "testuser", + role: "USER" as const, + locale: "en", + }, +}; + +const callGet = GET as unknown as (req: NextRequest) => Promise; +function makeReq(biomarkerId?: string): NextRequest { + const url = new URL("http://localhost/api/insights/biomarker-assessment"); + if (biomarkerId !== undefined) + url.searchParams.set("biomarkerId", biomarkerId); + return new NextRequest(url); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true }); +}); + +describe("GET /api/insights/biomarker-assessment", () => { + it("returns 200 + envelope and calls the generator read-only", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await callGet(makeReq("bm-1")); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { text: string; hasProvider: boolean } | null; + error: string | null; + }; + expect(body.error).toBeNull(); + expect(body.data?.text).toBe("ok"); + expect(generateBiomarkerStatus).toHaveBeenCalledWith( + expect.objectContaining({ + biomarkerId: "bm-1", + userId: "user-1", + readOnly: true, + }), + ); + }); + + it("422s on a missing biomarkerId without calling the generator", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await callGet(makeReq()); + expect(res.status).toBe(422); + expect(generateBiomarkerStatus).not.toHaveBeenCalled(); + }); + + it("401s when unauthenticated", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await callGet(makeReq("bm-1")); + expect(res.status).toBe(401); + }); + + it("403s + errorCode when insightStatus is disabled", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({ + assistantEnabled: true, + assistantCoachEnabled: true, + assistantBriefingEnabled: true, + assistantInsightStatusEnabled: false, + assistantCorrelationsEnabled: true, + assistantHealthScoreExplainerEnabled: true, + } as never); + const res = await callGet(makeReq("bm-1")); + expect(res.status).toBe(403); + const body = (await res.json()) as { meta?: { errorCode?: string } }; + expect(body.meta?.errorCode).toBe("assistant.disabled.insightStatus"); + }); +}); diff --git a/src/app/api/insights/biomarker-assessment/route.ts b/src/app/api/insights/biomarker-assessment/route.ts new file mode 100644 index 000000000..1bc0bd5af --- /dev/null +++ b/src/app/api/insights/biomarker-assessment/route.ts @@ -0,0 +1,72 @@ +/** + * Per-biomarker assessment route. + * + * `GET /api/insights/biomarker-assessment?biomarkerId=` serves the + * data-driven assessment for one user-scoped biomarker. The shape is + * byte-identical to `/api/insights/metric-status` so `InsightStatusCard` + * consumes it unchanged. + * + * Read-only by construction: a cache miss enqueues an out-of-band + * generation (via `resolveReadOnlyStatusMiss` inside the generator) and + * serves the last-good text (stale-while-revalidate), never blocking on the + * provider. The generator regenerates ONLY when the latest reading + * fingerprint changes, so an idle marker re-stamps cached text without an + * LLM round-trip. + */ +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; +import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { resolveServerLocale } from "@/lib/i18n/server-locale"; +import { requireAssistantSurface } from "@/lib/feature-flags"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { generateBiomarkerStatus } from "@/lib/insights/biomarker-status"; +import { resolveMetricStatusLocale } from "@/lib/insights/metric-status"; + +export const dynamic = "force-dynamic"; + +const querySchema = z.object({ + biomarkerId: z.string().min(1).max(64), +}); + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + const m = await requireModuleEnabled(user.id, "insights"); + if (!m.enabled) return m.response; + await requireAssistantSurface("insightStatus"); + + const parsed = querySchema.safeParse({ + biomarkerId: request.nextUrl.searchParams.get("biomarkerId"), + }); + if (!parsed.success) { + annotate({ + action: { name: "insights.biomarker-status.invalid" }, + meta: { issue_count: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422); + } + + const localeParam = request.nextUrl.searchParams.get("locale"); + const resolved = await resolveServerLocale({ + request, + userLocale: user.locale ?? null, + override: localeParam, + }); + const locale = resolveMetricStatusLocale(resolved); + + const result = await generateBiomarkerStatus({ + biomarkerId: parsed.data.biomarkerId, + userId: user.id, + locale, + force: false, + readOnly: true, + }); + + annotate({ + action: { name: "insights.biomarker-status" }, + meta: { biomarkerId: parsed.data.biomarkerId }, + }); + + return apiSuccess(result); +}); diff --git a/src/app/api/insights/breathing-screening/route.ts b/src/app/api/insights/breathing-screening/route.ts new file mode 100644 index 000000000..ee8cdf25b --- /dev/null +++ b/src/app/api/insights/breathing-screening/route.ts @@ -0,0 +1,73 @@ +/** + * v1.25 — breathing-disturbance screening read. + * + * GET /api/insights/breathing-screening + * + * Reads the last ~30 days of per-night sleep-breathing-disturbance index + * (`BREATHING_DISTURBANCES`) and device-flagged breathing-disturbance / apnea + * events (`BREATHING_DISTURBANCE_EVENT`) and summarises them into a calm + * awareness read (nights, recent mean, trend, event count, the device's own + * classification). SCREENING SIGNAL ONLY — never a HealthLog diagnosis; the + * card states this explicitly. + * + * Mirrors `/api/insights/derived`: `apiHandler`, `requireAuth`, the `insights` + * module gate, the shared analytics-read budget. `userId` is narrowed from the + * session. + */ +import { apiError, apiSuccess } from "@/lib/api-response"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { prisma } from "@/lib/db"; +import { summariseBreathing } from "@/lib/insights/breathing-screening"; + +export const dynamic = "force-dynamic"; + +const WINDOW_DAYS = 30; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + + const m = await requireModuleEnabled(user.id, "insights"); + if (!m.enabled) return m.response; + + const rl = await checkAnalyticsReadRateLimit(user.id); + if (!rl.allowed) { + return apiError("Too many analytics requests. Please retry later.", 429); + } + + const now = new Date(); + const since = new Date(now.getTime() - WINDOW_DAYS * 24 * 60 * 60 * 1000); + + const rows = await prisma.measurement.findMany({ + where: { + userId: user.id, + type: { in: ["BREATHING_DISTURBANCES", "BREATHING_DISTURBANCE_EVENT"] }, + measuredAt: { gte: since }, + }, + select: { type: true, value: true, measuredAt: true }, + orderBy: { measuredAt: "asc" }, + }); + + const indexRows = rows + .filter((r) => r.type === "BREATHING_DISTURBANCES") + .map((r) => ({ value: r.value, measuredAt: r.measuredAt })); + const eventRows = rows + .filter((r) => r.type === "BREATHING_DISTURBANCE_EVENT") + .map((r) => ({ value: r.value, measuredAt: r.measuredAt })); + + const summary = summariseBreathing(indexRows, eventRows); + + annotate({ + action: { name: "insights.breathing-screening.read" }, + meta: { + present: summary.present, + nights: summary.nights, + events: summary.eventCount, + classification: summary.classification ?? "none", + }, + }); + + return apiSuccess({ ...summary, generatedAt: now.toISOString() }); +}); diff --git a/src/app/api/insights/coach/seeded-question/__tests__/route.test.ts b/src/app/api/insights/coach/seeded-question/__tests__/route.test.ts index b5354a1bc..d01477160 100644 --- a/src/app/api/insights/coach/seeded-question/__tests__/route.test.ts +++ b/src/app/api/insights/coach/seeded-question/__tests__/route.test.ts @@ -8,7 +8,12 @@ import { NextRequest } from "next/server"; * `{ signal: null }` and the hero keeps its neutral greeting — never a * fabricated opener. */ -vi.mock("@/lib/db", () => ({ prisma: {} })); +const findUniqueMock = vi.fn().mockResolvedValue({ notificationPrefs: null }); +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: (...args: unknown[]) => findUniqueMock(...args) }, + }, +})); vi.mock("@/lib/modules/gate", async (importOriginal) => ({ ...(await importOriginal()), @@ -75,6 +80,7 @@ const SESSION_OK = { beforeEach(() => { vi.clearAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + findUniqueMock.mockResolvedValue({ notificationPrefs: null }); }); describe("GET /api/insights/coach/seeded-question", () => { @@ -124,4 +130,20 @@ describe("GET /api/insights/coach/seeded-question", () => { expect(body.error).toBeNull(); expect(body.data?.signal).toBeNull(); }); + + it("returns { signal: null } and skips the detector when ambient suggestions are off", async () => { + findUniqueMock.mockResolvedValue({ + notificationPrefs: { coach: { ambientSuggestions: false } }, + }); + const res = await callGet(makeReq()); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { signal: unknown } | null; + error: string | null; + }; + expect(body.error).toBeNull(); + expect(body.data?.signal).toBeNull(); + // The opt-out short-circuits before the compute tier. + expect(detectDerivedBriefingSignals).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/api/insights/coach/seeded-question/route.ts b/src/app/api/insights/coach/seeded-question/route.ts index 8e5ac4a0e..457099e22 100644 --- a/src/app/api/insights/coach/seeded-question/route.ts +++ b/src/app/api/insights/coach/seeded-question/route.ts @@ -26,6 +26,7 @@ import { requireAssistantSurface } from "@/lib/feature-flags"; import { prisma } from "@/lib/db"; import { loadBaselineProfile } from "@/lib/insights/derived"; import { detectDerivedBriefingSignals } from "@/lib/insights/derived-briefing"; +import { resolveCoachAmbientSuggestionsEnabled } from "@/lib/validations/notification-prefs"; export const dynamic = "force-dynamic"; @@ -60,6 +61,23 @@ export const GET = apiHandler(async () => { return apiError("Too many analytics requests. Please retry later.", 429); } + // Server-authoritative ambient-suggestions opt-out: when the user turns + // proactive suggestions off the hero must not seed an opener, so the route + // skips the detector work and returns the neutral `{ signal: null }` shape. + const prefsRow = await prisma.user.findUnique({ + where: { id: user.id }, + select: { notificationPrefs: true }, + }); + if ( + !resolveCoachAmbientSuggestionsEnabled(prefsRow?.notificationPrefs ?? null) + ) { + annotate({ + action: { name: "coach.seeded-question.resolve" }, + meta: { has_signal: false, source_metric: "none", band: "none" }, + }); + return apiSuccess({ signal: null } satisfies CoachSeededQuestionDTO); + } + // Profile read once via the shared loader; passed into the detector, // never re-fetched per candidate (the detector probes readiness + // recovery off the one profile). diff --git a/src/app/api/insights/comprehensive/__tests__/route.test.ts b/src/app/api/insights/comprehensive/__tests__/route.test.ts index 97ea186cd..221b47d5f 100644 --- a/src/app/api/insights/comprehensive/__tests__/route.test.ts +++ b/src/app/api/insights/comprehensive/__tests__/route.test.ts @@ -128,6 +128,11 @@ beforeEach(() => { dateOfBirth: new Date("1985-01-01"), }); (prisma.moodEntry.findMany as ReturnType).mockResolvedValue([]); + // The resting-pulse pairing (M-CS2) reads RESTING_HEART_RATE rows (and the + // raw-PULSE proxy only when none exist) straight from the measurement table. + (prisma.measurement.findMany as ReturnType).mockResolvedValue( + [], + ); ( prisma.moodEntryRollup.findMany as ReturnType ).mockResolvedValue([]); diff --git a/src/app/api/insights/comprehensive/route.ts b/src/app/api/insights/comprehensive/route.ts index d59471136..5f5eb76ad 100644 --- a/src/app/api/insights/comprehensive/route.ts +++ b/src/app/api/insights/comprehensive/route.ts @@ -21,6 +21,7 @@ import { SCHEDULE_COMPLIANCE_SELECT, } from "@/lib/analytics/compliance"; import { getMedicationCategories } from "@/lib/medication-category"; +import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse"; import { apiHandler, requireAuth, type AuthContext } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { requireAssistantSurface } from "@/lib/feature-flags"; @@ -263,8 +264,55 @@ export async function buildComprehensiveResponse(user: AuthedUser) { weight: p.b, })); - const pulseDaily = aggregate.dailyByType.PULSE ?? []; - const moodPulsePairs = pairMoodWithDaily(dailyMoodEntries, pulseDaily); + // v1.2.5 (M-CS2) — this correlation is labelled "Resting pulse (bpm)"; + // score it against the RESTING series, not the raw PULSE day-mean. The + // aggregator's `dailyByType.PULSE` is a per-day AVG that folds in workout + // HR for Apple-Health users and inflates the "resting" figure. Prefer the + // clean RESTING_HEART_RATE rows; only pull RAW PULSE (for the + // low-percentile daily proxy) when the user has no resting rows — the + // proxy needs the per-day distribution, which the day-mean discards. + // Day keys follow this route's UTC-bucket convention (the per-user-tz + // bucketing is the documented v1.5 follow-up) so the resting series pairs + // against the same UTC day keys as the mood series. + const restingPulseRows = await prisma.measurement.findMany({ + where: { + userId, + type: "RESTING_HEART_RATE", + deletedAt: null, + measuredAt: { gte: ninetyDaysAgo }, + }, + select: { measuredAt: true, value: true }, + }); + const rawPulseRowsForResting = + restingPulseRows.length === 0 + ? await prisma.measurement.findMany({ + where: { + userId, + type: "PULSE", + deletedAt: null, + measuredAt: { gte: ninetyDaysAgo }, + }, + select: { measuredAt: true, value: true }, + }) + : []; + const utcDayKey = (d: Date) => d.toISOString().slice(0, 10); + const { series: restingPulseSeries } = resolveRestingPulseSeries({ + restingSamples: restingPulseRows, + pulseSamples: rawPulseRowsForResting, + dayKeyOf: utcDayKey, + }); + const restingByDay = new Map(); + for (const s of restingPulseSeries) { + const key = utcDayKey(s.measuredAt); + const cur = restingByDay.get(key) ?? { sum: 0, count: 0 }; + cur.sum += s.value; + cur.count += 1; + restingByDay.set(key, cur); + } + const restingPulseDaily = Array.from(restingByDay.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([day, stats]) => ({ day, value: stats.sum / stats.count })); + const moodPulsePairs = pairMoodWithDaily(dailyMoodEntries, restingPulseDaily); const moodPulseCorrelation = pearsonCorrelation(moodPulsePairs); const moodPulseScatterData = moodPulsePairs.map((p) => ({ mood: p.a, @@ -283,6 +331,8 @@ export async function buildComprehensiveResponse(user: AuthedUser) { schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, // v1.16.3 — archived schedule eras for era-aware compliance. scheduleRevisions: { orderBy: { validFrom: "asc" } }, + // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. + pauseEras: { select: { pausedAt: true, resumedAt: true } }, }, }); const categoryMap = await getMedicationCategories( diff --git a/src/app/api/insights/correlations/__tests__/route.test.ts b/src/app/api/insights/correlations/__tests__/route.test.ts index a4d8b711f..a584897c9 100644 --- a/src/app/api/insights/correlations/__tests__/route.test.ts +++ b/src/app/api/insights/correlations/__tests__/route.test.ts @@ -20,6 +20,10 @@ vi.mock("@/lib/db", () => ({ illnessDayLog: { findMany: vi.fn().mockResolvedValue([]) }, // v1.22 — labs ↔ outcome pass fetches lab draws; default empty. labResult: { findMany: vi.fn().mockResolvedValue([]) }, + // v1.25 (W-ENV) — the route folds the environmental-exposure behaviour + // channels in (read from EnvironmentContext); default empty so no env + // channel produces a point. + environmentContext: { findMany: vi.fn().mockResolvedValue([]) }, }, })); @@ -113,6 +117,9 @@ beforeEach(() => { ); // v1.22 — labs ↔ outcome pass reads lab draws; default empty. (prisma.labResult.findMany as ReturnType).mockResolvedValue([]); + ( + prisma.environmentContext.findMany as ReturnType + ).mockResolvedValue([]); }); const callGet = GET as unknown as (req: NextRequest) => Promise; diff --git a/src/app/api/insights/correlations/route.ts b/src/app/api/insights/correlations/route.ts index 33cf3329a..4256e6274 100644 --- a/src/app/api/insights/correlations/route.ts +++ b/src/app/api/insights/correlations/route.ts @@ -39,6 +39,7 @@ import { } from "@/lib/insights/correlation-discovery"; import { fetchComplianceSeries, + fetchEnvironmentSeries, fetchLabDraws, fetchSymptomSeries, } from "@/lib/insights/correlation-channel-series"; @@ -152,11 +153,16 @@ export const GET = apiHandler(async () => { // their own sources. Each degrades to an empty series when the user has no // data, so the discovery loop drops the channel (it cannot clear n ≥ 20). // v1.22 — lab draws (for the labs ↔ outcome pass) fetch alongside. - const [complianceSeries, symptomSeries, labDraws] = await Promise.all([ - fetchComplianceSeries(user.id, tz, since), - fetchSymptomSeries(user.id, tz, since), - fetchLabDraws(user.id, tz, since), - ]); + const [complianceSeries, symptomSeries, labDraws, environmentSeries] = + await Promise.all([ + fetchComplianceSeries(user.id, tz, since), + fetchSymptomSeries(user.id, tz, since), + fetchLabDraws(user.id, tz, since), + // v1.25 (W-ENV) — environmental-exposure behaviour channels (weather / + // daylight). Empty when the module is off / no home set, so the channels + // degrade to absent. The module gate is implicit: no rows ⇒ no channels. + fetchEnvironmentSeries(user.id, since), + ]); const points = (key: string): DailySeriesPoint[] => key === "MOOD" @@ -180,6 +186,12 @@ export const GET = apiHandler(async () => { series.push({ key, role: "outcome", points: points(key) }); } } + // v1.25 (W-ENV) — fold the environmental-exposure behaviour channels in. They + // pair (lag D → D+1) against every outcome above; the n ≥ 20 / FDR / effect- + // size gates apply unchanged, so a thin weather series degrades to absent. + for (const envSeries of environmentSeries) { + series.push(envSeries); + } const result = discoverCorrelations(series); @@ -213,6 +225,13 @@ export const GET = apiHandler(async () => { emerging_window_days: emerging.windowDays, lab_draws: labDraws.length, lab_correlations: labCorrelations.discovered.length, + // v1.25 (W-ENV) — env channel reach (sum of stored daily points across + // the exposure channels) so a dashboard can see whether weather was + // available for the scan. + environment_days: environmentSeries.reduce( + (sum, s) => sum + s.points.length, + 0, + ), }, }); diff --git a/src/app/api/insights/generate/__tests__/route.test.ts b/src/app/api/insights/generate/__tests__/route.test.ts index 355250d4a..550817bd4 100644 --- a/src/app/api/insights/generate/__tests__/route.test.ts +++ b/src/app/api/insights/generate/__tests__/route.test.ts @@ -48,6 +48,11 @@ vi.mock("@/lib/db", () => ({ // generation. The test prisma mock has to surface the call so // the cache-invalidation test can assert against it. deleteMany: vi.fn(async () => ({ count: 0 })), + // v1.25 — the GET read path consults the briefing-failure marker + // (`readBriefingFailure`) and the failure paths append one + // (`recordBriefingFailure`). Default to no marker. + findFirst: vi.fn(async () => null), + create: vi.fn(async () => ({})), }, // v1.12.1 — the server-managed consent gate reads the latest active // receipt before egress. These fixtures resolve to an admin-openai diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 72c5af606..5dcf2081c 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -61,6 +61,11 @@ import { type InsightResult, } from "@/lib/ai/types"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; +import { resolveEffectiveTimeoutMs } from "@/lib/ai/effective-timeout"; +import { + recordBriefingFailure, + readBriefingFailure, +} from "@/lib/insights/briefing-failure-marker"; import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider"; import { AllProvidersFailedError, @@ -221,9 +226,10 @@ async function buildComparisonSnapshotForUser( orderBy: { measuredAt: "asc" }, select: { measuredAt: true, value: true }, }); - dataPoints = measurements.map( - (m): DataPoint => ({ date: m.measuredAt, value: m.value }), - ); + dataPoints = measurements.map((m): DataPoint => ({ + date: m.measuredAt, + value: m.value, + })); } const summary = summarize(dataPoints); const baselineAvg = @@ -307,6 +313,15 @@ export const GET = apiHandler(async (request: NextRequest) => { // connect-an-AI-provider hint instead of implying the read is live. const hasProvider = await hasUsableStatusProvider(userId); + // v1.25 — surface whether the most recent generation attempt failed (a + // marker newer than the last successful generation). The briefing keeps its + // last good text on failure, so this is the only honest signal that a shown + // briefing is held rather than freshly refreshed — and, when there is no + // last good text, that the empty state should read "couldn't generate" with + // a retry rather than the generic "no briefing yet". + const generationFailed = + (await readBriefingFailure({ userId, since: cachedAt })) !== null; + // Read-only: never block on the provider. Warm out of band only when the // cached briefing is stale / missing AND a provider is configured (a // provider-less account costs one cheap chain-resolve and shows the @@ -351,6 +366,8 @@ export const GET = apiHandler(async (request: NextRequest) => { // the served briefing can never refresh, so the UI pairs its age // with a connect-provider affordance. hasProvider, + // v1.25 — true when the last generation attempt failed (held text). + generationFailed, }); } catch { // Invalid cache row — fall through to the empty payload below. The @@ -370,6 +387,9 @@ export const GET = apiHandler(async (request: NextRequest) => { revalidating, // v1.18.9 (#4) — see the cached branch above. hasProvider, + // v1.25 — no last-good text AND the last attempt failed: the UI shows a + // "couldn't generate" empty state with a retry instead of the generic one. + generationFailed, }); }); @@ -403,9 +423,17 @@ export const POST = apiHandler((request: NextRequest) => heightCm: true, // v1.22 (W6) — first name for the sparse, hash-gated briefing opener. displayName: true, + // v1.25 — per-user response-timeout (seconds), threaded onto the + // generation calls so a slow self-hosted backend honours the setting. + aiResponseTimeoutSeconds: true, }, }); + const effectiveTimeoutMs = resolveEffectiveTimeoutMs( + dbUser?.aiResponseTimeoutSeconds, + AI_BUDGETS.comprehensive.timeoutMs, + ); + const locale = await resolveServerLocale({ request, userLocale: dbUser?.locale ?? user.locale ?? null, @@ -717,7 +745,8 @@ export const POST = apiHandler((request: NextRequest) => // v1.21.5 — wider upstream budget so the reasoning-heavy briefing // generation is not aborted at the client's 60 s default on large // accounts (which returned an empty briefing + trend narrative). - timeoutMs: AI_BUDGETS.comprehensive.timeoutMs, + // v1.25 — honours the per-user response-timeout setting. + timeoutMs: effectiveTimeoutMs, // The reply is parsed with `JSON.parse` below, so opt the OpenAI / // Codex chains into their strict JSON mode (gated on this flag). responseFormat: "json", @@ -727,6 +756,18 @@ export const POST = apiHandler((request: NextRequest) => workingProviderType = fallback.workingProvider.providerType; fallbackHopCount = fallback.fallbackHops.length; } catch (e) { + // v1.25 — record a dated failure marker (no cache row is written on this + // path, so the last good briefing stays intact). The read path pairs the + // preserved text with a discreet "couldn't refresh" hint, or shows a + // retry empty state when there is no last good text. + void recordBriefingFailure({ + userId, + reason: + e instanceof AllProvidersFailedError + ? "all-providers-failed" + : "provider-error", + locale, + }); if (e instanceof AllProvidersFailedError) { annotate({ meta: { @@ -826,6 +867,7 @@ export const POST = apiHandler((request: NextRequest) => // breaks `await res.json()` on the client side. 422 stays passthrough // so the React Query mutation can read the JSON body and surface a // readable message. Same fix pattern as v1.4.5 ai/test. + void recordBriefingFailure({ userId, reason: "invalid-json", locale }); return apiError("AI response was not valid JSON", 422); } @@ -856,7 +898,8 @@ export const POST = apiHandler((request: NextRequest) => temperature: 0.3, maxTokens: 1500, // v1.21.5 — wider upstream budget; see the first generation call. - timeoutMs: AI_BUDGETS.comprehensive.timeoutMs, + // v1.25 — honours the per-user response-timeout setting. + timeoutMs: effectiveTimeoutMs, // Parsed with `JSON.parse` below — opt OpenAI / Codex into JSON mode. responseFormat: "json", }), diff --git a/src/app/api/insights/glp1-timeline/route.ts b/src/app/api/insights/glp1-timeline/route.ts index c5b8711d7..8992b233f 100644 --- a/src/app/api/insights/glp1-timeline/route.ts +++ b/src/app/api/insights/glp1-timeline/route.ts @@ -12,6 +12,7 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiSuccess } from "@/lib/api-response"; +import { readNote } from "@/lib/crypto/note-cipher"; import { NextRequest } from "next/server"; const DEFAULT_LIMIT = 60; @@ -131,7 +132,7 @@ export const GET = apiHandler(async (request: NextRequest) => { doseValue: dc.doseValue, doseUnit: dc.doseUnit, doseDelta: delta, - note: dc.note, + note: readNote(dc.noteEncrypted, dc.note), }); } for (const intake of med.intakeEvents) { diff --git a/src/app/api/insights/health-status/route.ts b/src/app/api/insights/health-status/route.ts new file mode 100644 index 000000000..0b179058a --- /dev/null +++ b/src/app/api/insights/health-status/route.ts @@ -0,0 +1,67 @@ +/** + * v1.25 — health-status / baseline-drift read. + * + * GET /api/insights/health-status + * + * Surfaces what is drifting from the user's own normal, drawn off two existing + * read-only engines — the personal-band deviations from the coincident- + * deviation flag (`coincident-deviation.ts`) and the dated, sustained level + * shifts from the changepoint detector (`changepoint.ts`). Pure compute over + * the rollup tier: no provider call, no fabricated value, never a diagnosis. + * + * Mirrors `/api/insights/derived`: `apiHandler` wrapper, `requireAuth`, the + * `insights` module gate, the shared analytics-read budget. `userId` is always + * narrowed from the session. The labels are mapped client-side from the + * MeasurementType, so the wire carries the type tokens only. + */ +import { apiError, apiSuccess } from "@/lib/api-response"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { prisma } from "@/lib/db"; +import { loadBaselineProfile } from "@/lib/insights/derived"; +import { computeCoincidentDeviation } from "@/lib/insights/derived/coincident-deviation"; +import { buildChangepointSignals } from "@/lib/insights/derived/changepoint"; +import { summariseHealthStatus } from "@/lib/insights/health-status"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + + const m = await requireModuleEnabled(user.id, "insights"); + if (!m.enabled) return m.response; + + const rl = await checkAnalyticsReadRateLimit(user.id); + if (!rl.allowed) { + return apiError("Too many analytics requests. Please retry later.", 429); + } + + const now = new Date(); + const profile = await loadBaselineProfile(prisma, user.id); + + const coincident = await computeCoincidentDeviation(user.id, profile, { + tz: user.timezone, + now, + }); + const shifts = await buildChangepointSignals(user.id, now); + + const vitals = coincident.status === "ok" ? coincident.value.vitals : []; + const summary = summariseHealthStatus(vitals, shifts); + + annotate({ + action: { name: "insights.health-status.read" }, + meta: { + deviations: summary.deviations.length, + shifts: summary.shifts.length, + }, + }); + + return apiSuccess({ + present: summary.present, + deviations: summary.deviations, + shifts: summary.shifts, + generatedAt: now.toISOString(), + }); +}); diff --git a/src/app/api/insights/labs-changes/route.ts b/src/app/api/insights/labs-changes/route.ts new file mode 100644 index 000000000..518fc417a --- /dev/null +++ b/src/app/api/insights/labs-changes/route.ts @@ -0,0 +1,67 @@ +/** + * v1.25 — "what changed since your last panel" read. + * + * GET /api/insights/labs-changes + * + * Groups the user's live numeric lab results by panel date, pairs the two + * most-recent panels, and reports the per-analyte delta + reference-band + * standing (`summariseLabChanges`). Absent when there are fewer than two + * panels or no analyte is shared. Neutral framing only — a delta is not a + * diagnosis. + * + * Mirrors `/api/insights/derived`: `apiHandler`, `requireAuth`, the `insights` + * module gate, the shared analytics-read budget. `userId` is narrowed from the + * session; soft-deleted rows (`deletedAt`) are filtered out. + */ +import { apiError, apiSuccess } from "@/lib/api-response"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { prisma } from "@/lib/db"; +import { summariseLabChanges } from "@/lib/insights/labs-changes"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + + const m = await requireModuleEnabled(user.id, "insights"); + if (!m.enabled) return m.response; + + const rl = await checkAnalyticsReadRateLimit(user.id); + if (!rl.allowed) { + return apiError("Too many analytics requests. Please retry later.", 429); + } + + const rows = await prisma.labResult.findMany({ + where: { userId: user.id, deletedAt: null, value: { not: null } }, + select: { + analyte: true, + unit: true, + value: true, + referenceLow: true, + referenceHigh: true, + takenAt: true, + }, + orderBy: { takenAt: "desc" }, + }); + + const summary = summariseLabChanges( + rows.map((r) => ({ + analyte: r.analyte, + unit: r.unit, + value: r.value as number, + referenceLow: r.referenceLow, + referenceHigh: r.referenceHigh, + takenAt: r.takenAt, + })), + ); + + annotate({ + action: { name: "insights.labs-changes.read" }, + meta: { present: summary.present, changes: summary.changes.length }, + }); + + return apiSuccess({ ...summary, generatedAt: new Date().toISOString() }); +}); diff --git a/src/app/api/insights/layout/route.ts b/src/app/api/insights/layout/route.ts index 88a242ece..1598ab829 100644 --- a/src/app/api/insights/layout/route.ts +++ b/src/app/api/insights/layout/route.ts @@ -35,7 +35,7 @@ import { Prisma } from "@/generated/prisma/client"; import { z } from "zod/v4"; import { invalidateUserInsightsLayout } from "@/lib/cache/invalidate"; import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; -import { redactSensitiveFields } from "@/lib/observability/redact-payload"; +import { redactForExcerpt } from "@/lib/observability/redact-payload"; import { shouldEmitAuditRow } from "@/lib/audit-dedup"; import type { NextRequest } from "next/server"; @@ -87,7 +87,11 @@ const layoutSchema = z.object({ }), ) .min(1) - .max(50) + // Cap at the accepted-id universe (canonical slugs + legacy aliases) so a + // single PUT can cover every tile the layout knows about — the cap grows + // automatically as new sub-page slugs land, rather than tripping a 422 + // once the slug count crosses a hard-coded ceiling. + .max(ACCEPTED_INSIGHTS_TILE_IDS.length) .optional(), }); @@ -129,9 +133,7 @@ export const PUT = apiHandler(async (request: NextRequest) => { // round-trip; the wide-event line carries the redacted payload // shape for operator debugging without leaking the raw body. const issues = sanitiseZodIssues(parsed.error.issues); - const payloadDiagnostic = buildPayloadDiagnostic( - redactSensitiveFields(body), - ); + const payloadDiagnostic = buildPayloadDiagnostic(redactForExcerpt(body)); annotate({ action: { name: "insights.layout.validation-failed" }, meta: { diff --git a/src/app/api/integrations/moodlog/test/__tests__/route.test.ts b/src/app/api/integrations/moodlog/test/__tests__/route.test.ts index b2a77a689..33ac0c7e5 100644 --- a/src/app/api/integrations/moodlog/test/__tests__/route.test.ts +++ b/src/app/api/integrations/moodlog/test/__tests__/route.test.ts @@ -1,5 +1,20 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +// safeFetch's requirePublicHost path runs through undici's own `fetch` +// (version-locked with its dispatcher). Delegate it to the global `fetch` +// stub these tests install so the existing interception still applies. +vi.mock("undici", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetch: (input: unknown, init?: unknown) => + (globalThis.fetch as unknown as (i: unknown, n?: unknown) => unknown)( + input, + init, + ), + }; +}); + vi.mock("@/lib/api-handler", () => ({ apiHandler: unknown>(fn: T) => fn, requireAuth: vi.fn(async () => ({ diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts index 0c4821d02..0d2ea94c7 100644 --- a/src/app/api/labs/route.ts +++ b/src/app/api/labs/route.ts @@ -179,6 +179,7 @@ async function postLabResult(request: NextRequest) { referenceHigh, takenAt, note, + source, } = parsed.data; // v1.18.9 — a qualitative reading carries `valueText` ("negativ" / …) instead @@ -239,7 +240,11 @@ async function postLabResult(request: NextRequest) { referenceLow: biomarker.lowerBound, referenceHigh: biomarker.upperBound, takenAt, - source: "MANUAL", + // v1.25 (iOS #36) — provenance: the on-device-OCR path posts + // `source: "OCR"`; an omitted field stays "MANUAL" so the legacy + // hand-entry contract is unchanged. Narrowed to the closed enum by Zod, + // never spread from the body. + source: source ?? "MANUAL", noteEncrypted: note ? encryptNoteToBytes(note) : null, }, }); @@ -258,6 +263,8 @@ async function postLabResult(request: NextRequest) { // Whether the client picked a catalog marker (structured) vs the // free-text path that resolved-or-minted one server-side. structured: biomarkerId !== undefined, + // v1.25 (iOS #36) — provenance of the reading ("MANUAL" | "OCR"). + source: source ?? "MANUAL", }, }); diff --git a/src/app/api/mcp/oauth/authorize/route.ts b/src/app/api/mcp/oauth/authorize/route.ts index 906d4677a..1abc2c847 100644 --- a/src/app/api/mcp/oauth/authorize/route.ts +++ b/src/app/api/mcp/oauth/authorize/route.ts @@ -118,16 +118,42 @@ interface ValidatedRequest { resource: string; } +/** + * The gate a request failed, pinned to a stable enum so the + * `mcp.oauth.authorize.invalid` annotation stays dashboard-friendly. The + * swallowed-reason gap (a bare `invalid` event) is exactly why the prod CIMD + * failure was undiagnosable. + */ +type ValidateFailure = + | "invalid_params" + | "client_unresolved" + | "redirect_mismatch" + | "bad_response_type" + | "pkce_required" + | "audience_mismatch"; + +type ValidateMeta = { + reason: ValidateFailure; + /** For `client_unresolved`: the `resolveClient` reason. */ + clientReason?: string; + /** For `client_unresolved`: a short, non-secret transport diagnostic. */ + detail?: string; +}; + /** Shared validation for GET + POST. Returns a ready error Response on failure. */ async function validate( params: Record, requestUrl: string, -): Promise<{ ok: true; v: ValidatedRequest } | { ok: false; res: Response }> { +): Promise< + | { ok: true; v: ValidatedRequest } + | { ok: false; res: Response; meta: ValidateMeta } +> { const parsed = z.object(baseParams).safeParse(params); if (!parsed.success) { return { ok: false, res: badRequest("invalid_request", "Missing or malformed parameters"), + meta: { reason: "invalid_params" }, }; } const p = parsed.data; @@ -139,6 +165,11 @@ async function validate( return { ok: false, res: badRequest("invalid_client", "Unknown or unresolvable client"), + meta: { + reason: "client_unresolved", + clientReason: resolved.reason, + ...(resolved.detail ? { detail: resolved.detail } : {}), + }, }; } @@ -150,6 +181,7 @@ async function validate( "invalid_request", "redirect_uri does not match the registered client", ), + meta: { reason: "redirect_mismatch" }, }; } @@ -161,6 +193,7 @@ async function validate( "unsupported_response_type", "Only response_type=code is supported", ), + meta: { reason: "bad_response_type" }, }; } @@ -175,6 +208,7 @@ async function validate( "invalid_request", "PKCE with code_challenge_method=S256 is required", ), + meta: { reason: "pkce_required" }, }; } @@ -186,6 +220,7 @@ async function validate( "invalid_target", "resource must equal this server's MCP endpoint", ), + meta: { reason: "audience_mismatch" }, }; } @@ -280,7 +315,10 @@ export async function GET(request: NextRequest): Promise { const params = Object.fromEntries(url.searchParams.entries()); const result = await validate(params, request.url); if (!result.ok) { - annotate({ action: { name: "mcp.oauth.authorize.invalid" } }); + annotate({ + action: { name: "mcp.oauth.authorize.invalid" }, + meta: result.meta, + }); return result.res; } const { v } = result; @@ -409,7 +447,10 @@ export async function POST(request: NextRequest): Promise { const result = await validate(params, request.url); if (!result.ok) { - annotate({ action: { name: "mcp.oauth.authorize.invalid" } }); + annotate({ + action: { name: "mcp.oauth.authorize.invalid" }, + meta: result.meta, + }); return result.res; } const { v } = result; diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index bb9e9b1d6..3748b429c 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -542,8 +542,7 @@ async function postBatch(request: NextRequest): Promise { unit: p.row.unit as string, measuredAt: p.row.measuredAt as Date, externalSourceVersion: p.row.externalSourceVersion as - | string - | null, + string | null, deviceType: p.row.deviceType as Prepared["row"]["deviceType"], sleepStage: p.row.sleepStage as Prepared["row"]["sleepStage"], }, diff --git a/src/app/api/measurements/by-external-ids/__tests__/route.test.ts b/src/app/api/measurements/by-external-ids/__tests__/route.test.ts new file mode 100644 index 000000000..c618a6bc8 --- /dev/null +++ b/src/app/api/measurements/by-external-ids/__tests__/route.test.ts @@ -0,0 +1,157 @@ +/** + * DELETE /api/measurements/by-external-ids — iOS HealthKit deletion-sync. + * + * Asserts the origin-scoped soft-delete contract (v1.25, iOS #35): the + * reconciliation only tombstones rows the app minted through its HealthKit + * ingestion path (`source = APPLE_HEALTH`). A foreign-origin externalId — one + * that collides with an integration- or manually-sourced row — is excluded by + * the source predicate and is a silent no-op, never a delete. Cross-user and + * idempotency guards stay as before. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { + findMany: vi.fn(), + updateMany: vi.fn(), + }, + auditLog: { create: vi.fn() }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserMeasurements: vi.fn(), +})); +vi.mock("@/lib/insights/comprehensive-generate", () => ({ + invalidateStatusInsightsForTypes: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/rollups/measurement-rollups", async () => { + const actual = await vi.importActual< + typeof import("@/lib/rollups/measurement-rollups") + >("@/lib/rollups/measurement-rollups"); + return { + collapseToTypeDayKeys: actual.collapseToTypeDayKeys, + recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined), + }; +}); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { DELETE } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; +import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; +import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups"; + +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 }, +}; + +function delReq(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/measurements/by-external-ids", { + method: "DELETE", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); + vi.mocked(recomputeBucketsForMeasurement).mockResolvedValue(undefined); + vi.mocked(invalidateStatusInsightsForTypes).mockResolvedValue(undefined); +}); + +describe("DELETE /api/measurements/by-external-ids", () => { + it("scopes both the pre-fetch and the soft-delete to the app-minted APPLE_HEALTH source", async () => { + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { type: "WEIGHT", measuredAt: new Date("2026-01-01T08:00:00Z") }, + ] as never); + vi.mocked(prisma.measurement.updateMany).mockResolvedValue({ + count: 1, + } as never); + + const res = await DELETE(delReq({ externalIds: ["uuid-aaa"] })); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { deletedCount: number } }; + expect(body.data.deletedCount).toBe(1); + + const findWhere = vi.mocked(prisma.measurement.findMany).mock.calls[0]![0]! + .where; + expect(findWhere).toMatchObject({ + userId: "user-1", + source: "APPLE_HEALTH", + externalId: { in: ["uuid-aaa"] }, + deletedAt: null, + }); + + const updateWhere = vi.mocked(prisma.measurement.updateMany).mock + .calls[0]![0].where; + expect(updateWhere).toMatchObject({ + userId: "user-1", + source: "APPLE_HEALTH", + externalId: { in: ["uuid-aaa"] }, + deletedAt: null, + }); + }); + + it("is a no-op for a foreign-origin externalId that collides on a non-app-minted row", async () => { + // The caller's reconciliation list carries an externalId that, on the + // server, maps to a WITHINGS-sourced row (a colliding externalUUID). The + // `source = APPLE_HEALTH` predicate excludes it, so the pre-fetch and the + // soft-delete both match zero rows. + vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.measurement.updateMany).mockResolvedValue({ + count: 0, + } as never); + + const res = await DELETE(delReq({ externalIds: ["withings-collision"] })); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { deletedCount: number } }; + expect(body.data.deletedCount).toBe(0); + + // The delete was still source-scoped (the foreign row was never eligible). + const updateWhere = vi.mocked(prisma.measurement.updateMany).mock + .calls[0]![0].where; + expect(updateWhere).toMatchObject({ source: "APPLE_HEALTH" }); + + // Zero deletions → no cache eviction, no rollup recompute, no insight bust. + expect(invalidateUserMeasurements).not.toHaveBeenCalled(); + expect(recomputeBucketsForMeasurement).not.toHaveBeenCalled(); + expect(invalidateStatusInsightsForTypes).not.toHaveBeenCalled(); + }); + + it("returns deletedCount 0 with 200 for an empty externalIds array", async () => { + const res = await DELETE(delReq({ externalIds: [] })); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { deletedCount: number } }; + expect(body.data.deletedCount).toBe(0); + expect(prisma.measurement.updateMany).not.toHaveBeenCalled(); + }); + + it("rejects a batch over the 500-id cap with 422", async () => { + const externalIds = Array.from({ length: 501 }, (_, i) => `uuid-${i}`); + const res = await DELETE(delReq({ externalIds })); + expect(res.status).toBe(422); + expect(prisma.measurement.updateMany).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/measurements/by-external-ids/route.ts b/src/app/api/measurements/by-external-ids/route.ts index 9c07d6a53..9914cb12b 100644 --- a/src/app/api/measurements/by-external-ids/route.ts +++ b/src/app/api/measurements/by-external-ids/route.ts @@ -10,6 +10,13 @@ * rather than removing the row, so deletions surface as tombstones on the * `/api/sync/changes` delta feed). * + * Origin scope (v1.25, iOS #35): the delete only touches rows the iOS + * client minted through its HealthKit ingestion path (`source = + * APPLE_HEALTH`). An externalId the app never minted — an integration- or + * manually-sourced row that happens to collide on externalUUID — is a + * no-op, never a delete. Defence-in-depth so a foreign HealthKit sample + * with a colliding UUID cannot be tombstoned by this reconciliation. + * * Idempotency contract: * - Cross-user safety: rows owned by another user are silently skipped * (Prisma's `updateMany` with `userId` in the where-clause guarantees @@ -105,9 +112,21 @@ async function deleteByExternalIds(request: NextRequest): Promise { // 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. + // + // v1.25 (iOS #35) — scope to the app's HealthKit ingestion source + // (`APPLE_HEALTH`). This reconciliation removes HealthKit samples the + // iOS client minted; only `source = APPLE_HEALTH` rows are app-minted on + // this path. Defence-in-depth against a colliding externalId: an + // integration- or manually-sourced row (WITHINGS / OURA / MANUAL / …) + // that happens to share an externalUUID with a now-absent HealthKit + // sample is NOT app-minted, so it falls outside the predicate and the + // delete is a no-op for it rather than tombstoning a foreign-origin row. + const APP_MINTED_SOURCE = "APPLE_HEALTH" as const; + const affectedRows = await prisma.measurement.findMany({ where: { userId: user.id, + source: APP_MINTED_SOURCE, externalId: { in: externalIds }, deletedAt: null, }, @@ -117,6 +136,7 @@ async function deleteByExternalIds(request: NextRequest): Promise { const result = await prisma.measurement.updateMany({ where: { userId: user.id, + source: APP_MINTED_SOURCE, externalId: { in: externalIds }, deletedAt: null, }, diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index 989c5fba4..50ec6ebd8 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -802,6 +802,7 @@ async function postMeasurement(request: NextRequest) { symptomsPresent: parsed.data.measurements.some( (m) => m.symptomsPresent === true, ), + timezone: user.timezone ?? undefined, }).catch(() => {}); // v1.5.0 — refresh the persistent rollup table for every distinct @@ -948,6 +949,7 @@ async function postMeasurement(request: NextRequest) { }, ], symptomsPresent, + timezone: user.timezone ?? undefined, }).catch(() => {}); // v1.5.0 — refresh the persistent rollup row for the affected diff --git a/src/app/api/measurements/series/route.ts b/src/app/api/measurements/series/route.ts index 23f16bc75..ca27049ec 100644 --- a/src/app/api/measurements/series/route.ts +++ b/src/app/api/measurements/series/route.ts @@ -20,7 +20,7 @@ import { } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { summarize, type DataPoint } from "@/lib/analytics/trends"; -import { redactSensitiveFields } from "@/lib/observability/redact-payload"; +import { redactForExcerpt } from "@/lib/observability/redact-payload"; import type { MeasurementType, SleepStage } from "@/generated/prisma/client"; import { reconstructSleepNights } from "@/lib/analytics/sleep-night"; import { VALUE_RANGES } from "@/lib/validations/measurement"; @@ -158,11 +158,11 @@ export const GET = apiHandler(async (request: NextRequest) => { // do not currently accept any but the truncation keeps that // invariant cheap) cannot leak. Shared helper via v1.4.49 so the // widget + series routes can't drift on the diagnostic shape; the - // query map is routed through `redactSensitiveFields` first so any + // query map is routed through `redactForExcerpt` first so any // future credential-shaped query string (`token`, `apiKey`, // `csrfState`) redacts to `"[redacted]"` before the truncate. const payloadDiagnostic = buildPayloadDiagnostic( - redactSensitiveFields(rawQuery), + redactForExcerpt(rawQuery), ); annotate({ action: { name: "measurements.series.validation-failed" }, diff --git a/src/app/api/medications/[id]/compliance/route.ts b/src/app/api/medications/[id]/compliance/route.ts index e6394b5e2..0a10216d2 100644 --- a/src/app/api/medications/[id]/compliance/route.ts +++ b/src/app/api/medications/[id]/compliance/route.ts @@ -71,6 +71,8 @@ export const GET = apiHandler( schedules: true, // v1.16.3 — archived schedule eras for era-aware compliance. scheduleRevisions: { orderBy: { validFrom: "asc" } }, + // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. + pauseEras: { select: { pausedAt: true, resumedAt: true } }, }, }); // Privacy gate — same sealed 404 for "absent" and "not yours". diff --git a/src/app/api/medications/[id]/glp1/route.ts b/src/app/api/medications/[id]/glp1/route.ts index ab3de40cb..220c1d7b2 100644 --- a/src/app/api/medications/[id]/glp1/route.ts +++ b/src/app/api/medications/[id]/glp1/route.ts @@ -39,6 +39,11 @@ import { resolveReorderLeadDays, } from "@/lib/validations/notification-prefs"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; +import { + encryptNote, + readNote, + shapeDoseChangeNote, +} from "@/lib/crypto/note-cipher"; import { NextRequest } from "next/server"; type RouteParams = { params: Promise<{ id: string }> }; @@ -193,7 +198,7 @@ export const GET = apiHandler( effectiveFrom: dc.effectiveFrom.toISOString(), doseValue: dc.doseValue, doseUnit: dc.doseUnit, - note: dc.note, + note: readNote(dc.noteEncrypted, dc.note), })), recentIntakes: medication.intakeEvents.map((iv) => ({ takenAt: iv.takenAt ? iv.takenAt.toISOString() : null, @@ -261,7 +266,9 @@ export const POST = apiHandler( effectiveFrom, doseValue, doseUnit, - note: note ?? null, + // Encrypt the titration note at rest; the plaintext column stays null. + noteEncrypted: encryptNote(note), + note: null, }, }); @@ -291,7 +298,7 @@ export const POST = apiHandler( // buckets so the card reflects the new runway on the next read. invalidateUserMedications(user.id, { evict: true }); - return apiSuccess({ doseChange: created }, 201); + return apiSuccess({ doseChange: shapeDoseChangeNote(created) }, 201); } // The schema's XOR refinement guarantees `inventory` is present diff --git a/src/app/api/medications/[id]/inventory/[itemId]/route.ts b/src/app/api/medications/[id]/inventory/[itemId]/route.ts index 2125b3c8f..df65ba109 100644 --- a/src/app/api/medications/[id]/inventory/[itemId]/route.ts +++ b/src/app/api/medications/[id]/inventory/[itemId]/route.ts @@ -37,6 +37,7 @@ import { } from "@/lib/medications/inventory/state-machine"; import { serializeInventoryItem } from "@/lib/medications/inventory/service"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; +import { encryptNote, shapeInventoryItemNotes } from "@/lib/crypto/note-cipher"; type RouteParams = { params: Promise<{ id: string; itemId: string }> }; @@ -142,6 +143,14 @@ export const PATCH = apiHandler( Date.now(), ); + // Only touch the note columns when the caller supplied a value; an absent + // `notes` leaves the existing ciphertext untouched. When present, encrypt + // at rest and null the plaintext column. + const notesData = + notes === undefined + ? {} + : { notesEncrypted: encryptNote(notes), notes: null }; + const updated = await prisma.medicationInventoryItem.update({ where: { id: itemId }, data: { @@ -150,7 +159,7 @@ export const PATCH = apiHandler( unitsRemaining: nextUnitsRemaining, printedExpiry: nextPrintedExpiry, expiresAt: nextExpiresAt, - notes: notes === undefined ? existing.notes : notes, + ...notesData, }, }); @@ -180,7 +189,7 @@ export const PATCH = apiHandler( // read instead of after the `cachedSwr` stale window. invalidateUserMedications(user.id, { evict: true }); - return apiSuccess(serializeInventoryItem(updated)); + return apiSuccess(serializeInventoryItem(shapeInventoryItemNotes(updated))); }, ); diff --git a/src/app/api/medications/[id]/inventory/route.ts b/src/app/api/medications/[id]/inventory/route.ts index a5ce6d0f2..d553ddee7 100644 --- a/src/app/api/medications/[id]/inventory/route.ts +++ b/src/app/api/medications/[id]/inventory/route.ts @@ -35,6 +35,7 @@ import { } from "@/lib/medications/inventory/service"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; +import { shapeInventoryItemNotes } from "@/lib/crypto/note-cipher"; type RouteParams = { params: Promise<{ id: string }> }; @@ -60,7 +61,11 @@ export const GET = apiHandler( }), ]); - const serialized = items.map(serializeInventoryItem); + // Decrypt each note and strip the raw `notesEncrypted` ciphertext before + // the decimal-unit serialisation runs. + const serialized = items.map((item) => + serializeInventoryItem(shapeInventoryItemNotes(item)), + ); // v1.19.0 (iOS#25) — the canonical supply summary is computed HERE, // server-side, through the one source of truth (`summariseSupply`). // The detail-page client renders these ready figures instead of @@ -162,6 +167,9 @@ export const POST = apiHandler( // serve the pre-write stock for the rest of the stale window. invalidateUserMedications(user.id, { evict: true }); - return apiSuccess(serializeInventoryItem(created), 201); + return apiSuccess( + serializeInventoryItem(shapeInventoryItemNotes(created)), + 201, + ); }, ); diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index c7fe4ecb3..ca2ec4e7a 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -679,6 +679,29 @@ export const PUT = apiHandler( } if (!medication) throw lastUpdateErr; + // v1.25 H-MED1 — durable pause intervals. The `pausedAt` column is a + // single live marker that resume clears, so the paused window is + // irrecoverable once resumed and every paused day's slot collapses to + // "missed" in the compliance denominator. Mirror the live transition + // onto an additive era record the rate paths read: a pause opens an era + // (resumedAt null), a resume closes the latest open era. `userId` comes + // from the authenticated session; the data object is built field-by-field. + if (active === false && existing.active) { + await prisma.medicationPauseEra.create({ + data: { + medicationId: id, + userId: user.id, + pausedAt: new Date(), + resumedAt: null, + }, + }); + } else if (active === true && !existing.active) { + await prisma.medicationPauseEra.updateMany({ + where: { medicationId: id, userId: user.id, resumedAt: null }, + data: { resumedAt: new Date() }, + }); + } + // Apply the v1.5.5 primary-schedule grace bridge after the // medication update so a failure here doesn't roll the medication // edit back. Re-read the row so the response shape carries the diff --git a/src/app/api/medications/[id]/side-effects/__tests__/route.test.ts b/src/app/api/medications/[id]/side-effects/__tests__/route.test.ts index edff25b8c..e30b8372a 100644 --- a/src/app/api/medications/[id]/side-effects/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/side-effects/__tests__/route.test.ts @@ -57,6 +57,13 @@ import { DELETE } from "../[logId]/route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { checkRateLimit } from "@/lib/rate-limit"; +import { readNote } from "@/lib/crypto/note-cipher"; + +// The POST path encrypts the free-text note at rest; drive the legacy single +// key so encryptNote has material to work with. +vi.stubEnv("ENCRYPTION_KEYS", ""); +vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", ""); +vi.stubEnv("ENCRYPTION_KEY", "a".repeat(64)); const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -233,17 +240,33 @@ describe("POST /api/medications/[id]/side-effects", () => { { params: Promise.resolve({ id: "med-1" }) }, ); expect(res.status).toBe(201); - expect(prisma.medicationSideEffect.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ - userId: "user-1", - medicationId: "med-1", - category: "GI", - entry: "NAUSEA", - severity: 2, - occurredAt: new Date(occurredAt), - notes: "after breakfast", - }), + const createArgs = vi.mocked(prisma.medicationSideEffect.create).mock + .calls[0][0] as { + data: { + userId: string; + medicationId: string; + category: string; + entry: string; + severity: number; + occurredAt: Date; + notes: string | null; + notesEncrypted: Uint8Array | null; + }; + }; + expect(createArgs.data).toMatchObject({ + userId: "user-1", + medicationId: "med-1", + category: "GI", + entry: "NAUSEA", + severity: 2, + occurredAt: new Date(occurredAt), + // v1.25 — the plaintext column is nulled; the note lands encrypted. + notes: null, }); + expect(createArgs.data.notesEncrypted).toBeInstanceOf(Uint8Array); + expect(readNote(createArgs.data.notesEncrypted, null)).toBe( + "after breakfast", + ); }); it("defaults occurredAt to now when omitted", async () => { diff --git a/src/app/api/medications/[id]/side-effects/route.ts b/src/app/api/medications/[id]/side-effects/route.ts index 899fa0067..5562e4873 100644 --- a/src/app/api/medications/[id]/side-effects/route.ts +++ b/src/app/api/medications/[id]/side-effects/route.ts @@ -36,6 +36,7 @@ import { } from "@/lib/medications/side-effects/validators"; import { categoryForEntry } from "@/lib/medications/side-effects/taxonomy"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; +import { encryptNote, shapeSideEffectNotes } from "@/lib/crypto/note-cipher"; type RouteParams = { params: Promise<{ id: string }> }; @@ -85,7 +86,12 @@ export const GET = apiHandler( meta: { medication_id: id, total: items.length }, }); - return apiSuccess({ items, meta: { total: items.length } }); + // Decrypt each note and strip the raw `notesEncrypted` ciphertext so it + // never leaves the server. + return apiSuccess({ + items: items.map(shapeSideEffectNotes), + meta: { total: items.length }, + }); }, ); @@ -139,7 +145,9 @@ export const POST = apiHandler( entry, severity, occurredAt: occurredAt ?? new Date(), - notes: notes ?? null, + // Encrypt the free-text note at rest; the plaintext column stays null. + notesEncrypted: encryptNote(notes), + notes: null, }, }); @@ -163,6 +171,6 @@ export const POST = apiHandler( meta: { medication_id: id, entry, severity }, }); - return apiSuccess(created, 201); + return apiSuccess(shapeSideEffectNotes(created), 201); }, ); diff --git a/src/app/api/medications/compliance/route.ts b/src/app/api/medications/compliance/route.ts index 6ee1eebfa..6c6263e02 100644 --- a/src/app/api/medications/compliance/route.ts +++ b/src/app/api/medications/compliance/route.ts @@ -50,6 +50,8 @@ export const GET = apiHandler(async () => { schedules: true, // v1.16.3 — archived schedule eras for era-aware compliance. scheduleRevisions: { orderBy: { validFrom: "asc" } }, + // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. + pauseEras: { select: { pausedAt: true, resumedAt: true } }, }, orderBy: { createdAt: "desc" }, }); diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index 0a348d87b..d50e5766c 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -256,6 +256,8 @@ async function buildScheduleAnchoredComplianceBuckets( schedules: true, // v1.16.3 — archived schedule eras for era-aware expected counts. scheduleRevisions: { orderBy: { validFrom: "asc" } }, + // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. + pauseEras: { select: { pausedAt: true, resumedAt: true } }, }, }); diff --git a/src/app/api/medications/layout/route.ts b/src/app/api/medications/layout/route.ts index 1fb39de3d..5f501a98d 100644 --- a/src/app/api/medications/layout/route.ts +++ b/src/app/api/medications/layout/route.ts @@ -34,7 +34,7 @@ import { Prisma } from "@/generated/prisma/client"; import { z } from "zod/v4"; import { invalidateUserMedicationListLayout } from "@/lib/cache/invalidate"; import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; -import { redactSensitiveFields } from "@/lib/observability/redact-payload"; +import { redactForExcerpt } from "@/lib/observability/redact-payload"; import { shouldEmitAuditRow } from "@/lib/audit-dedup"; import type { NextRequest } from "next/server"; @@ -92,9 +92,7 @@ export const PUT = apiHandler(async (request: NextRequest) => { // layout routes; the wide-event line carries the redacted payload // shape for operator debugging without leaking the raw body. const issues = sanitiseZodIssues(parsed.error.issues); - const payloadDiagnostic = buildPayloadDiagnostic( - redactSensitiveFields(body), - ); + const payloadDiagnostic = buildPayloadDiagnostic(redactForExcerpt(body)); annotate({ action: { name: "medication.layout.validation-failed" }, meta: { diff --git a/src/app/api/mental-health/assessments/__tests__/route.test.ts b/src/app/api/mental-health/assessments/__tests__/route.test.ts new file mode 100644 index 000000000..aba10d668 --- /dev/null +++ b/src/app/api/mental-health/assessments/__tests__/route.test.ts @@ -0,0 +1,284 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +/** + * v1.25.0 — `POST /api/mental-health/assessments` happy path. + * + * Regression guard for the dead-on-arrival bug: the handler used to pass the + * `safeJson` `{ data, error }` WRAPPER straight into `safeParse`, so the schema + * never matched and every POST returned 422. This pins that a valid body now + * (a) parses, (b) persists with a server-authoritative total / band / item-9 + * flag, (c) writes the derived `*_SCORE` Measurement, and (d) surfaces the + * locale-aware crisis set on a positive item 9. + */ + +vi.mock("@/lib/db", () => ({ + prisma: { + appSettings: { findUnique: vi.fn().mockResolvedValue(null) }, + mentalHealthAssessment: { create: vi.fn(), findFirst: vi.fn() }, + measurement: { create: vi.fn().mockResolvedValue({}) }, + }, +})); + +vi.mock("@/lib/modules/gate", async (importOriginal) => ({ + ...(await importOriginal()), + requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), + resolveModuleMap: vi.fn().mockResolvedValue({}), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi + .fn() + .mockResolvedValue({ allowed: true, remaining: 29, resetAt: Date.now() }), + rateLimitHeaders: vi.fn().mockReturnValue({}), +})); + +vi.mock("@/lib/ai/coach/bytes-codec", () => ({ + encryptToBytes: vi.fn(() => Buffer.from("ciphertext")), +})); + +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("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { prisma } from "@/lib/db"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "testuser", + role: "USER" as const, + locale: "en", + }, +}; + +const callPost = POST as unknown as (req: NextRequest) => Promise; + +function makeReq(body: unknown): NextRequest { + return new NextRequest( + new URL("http://localhost/api/mental-health/assessments"), + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }, + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true }); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 29, + resetAt: Date.now(), + }); + // No prior administration by default; the dedup test overrides this. + vi.mocked(prisma.mentalHealthAssessment.findFirst).mockResolvedValue( + null as never, + ); +}); + +describe("POST /api/mental-health/assessments", () => { + it("parses a valid PHQ-9 body and returns 201 with the server-computed total/band/flag", async () => { + // Item 9 = 2 → safety-flagged; total = 8*1 + 2 = 10 → moderate band. + vi.mocked(prisma.mentalHealthAssessment.create).mockResolvedValue({ + id: "mha_1", + instrument: "PHQ9", + locale: "en", + version: "standard", + totalScore: 10, + severityBand: "moderate", + item9Flagged: true, + crisisShownAt: new Date("2026-06-28T00:00:00.000Z"), + takenAt: new Date("2026-06-28T00:00:00.000Z"), + createdAt: new Date("2026-06-28T00:00:00.000Z"), + } as never); + + const res = await callPost( + makeReq({ + instrument: "PHQ9", + items: [1, 1, 1, 1, 1, 1, 1, 1, 2], + locale: "en", + }), + ); + + expect(res.status).toBe(201); + const body = (await res.json()) as { + data: { + assessment: { + totalScore: number; + severityBand: string; + item9Flagged: boolean; + }; + actionThreshold: number; + crisis: { emergencyNumber: string } | null; + }; + }; + expect(body.data.assessment.totalScore).toBe(10); + expect(body.data.assessment.severityBand).toBe("moderate"); + expect(body.data.assessment.item9Flagged).toBe(true); + expect(body.data.actionThreshold).toBe(10); + // Positive item 9 → crisis resources surfaced immediately. + expect(body.data.crisis).not.toBeNull(); + expect(body.data.crisis?.emergencyNumber).toBeTruthy(); + }); + + it("persists server-authoritative scoring (userId from session, total recomputed) + the derived measurement", async () => { + vi.mocked(prisma.mentalHealthAssessment.create).mockResolvedValue({ + id: "mha_2", + instrument: "GAD7", + locale: "en", + version: "standard", + totalScore: 6, + severityBand: "mild", + item9Flagged: false, + crisisShownAt: null, + takenAt: new Date("2026-06-28T00:00:00.000Z"), + createdAt: new Date("2026-06-28T00:00:00.000Z"), + } as never); + + // GAD-7, total 1+2+0+1+1+1+0 = 6 → mild, no safety item. + const res = await callPost( + makeReq({ + instrument: "GAD7", + items: [1, 2, 0, 1, 1, 1, 0], + }), + ); + expect(res.status).toBe(201); + + const createArg = vi.mocked(prisma.mentalHealthAssessment.create).mock + .calls[0][0] as { + data: { + userId: string; + instrument: string; + totalScore: number; + severityBand: string; + item9Flagged: boolean; + }; + }; + expect(createArg.data.userId).toBe("user-1"); + expect(createArg.data.instrument).toBe("GAD7"); + expect(createArg.data.totalScore).toBe(6); + expect(createArg.data.severityBand).toBe("mild"); + expect(createArg.data.item9Flagged).toBe(false); + + // Derived projection: one PHQ9_SCORE/GAD7_SCORE Measurement per assessment. + const measArg = vi.mocked(prisma.measurement.create).mock.calls[0][0] as { + data: { type: string; value: number; unit: string; externalId: string }; + }; + expect(measArg.data.type).toBe("GAD7_SCORE"); + expect(measArg.data.value).toBe(6); + expect(measArg.data.unit).toBe("score"); + expect(measArg.data.externalId).toBe("assessment:mha_2"); + }); + + it("dedups a repeat externalId: returns the existing administration, writes nothing new", async () => { + // The outbox replays a queued check-in with the same client externalId. + vi.mocked(prisma.mentalHealthAssessment.findFirst).mockResolvedValue({ + id: "mha_dup", + instrument: "PHQ9", + locale: "en", + version: "standard", + totalScore: 4, + severityBand: "mild", + item9Flagged: false, + crisisShownAt: null, + takenAt: new Date("2026-06-28T00:00:00.000Z"), + createdAt: new Date("2026-06-28T00:00:00.000Z"), + } as never); + + const res = await callPost( + makeReq({ + instrument: "PHQ9", + items: [1, 1, 1, 1, 0, 0, 0, 0, 0], + source: "IOS", + externalId: "outbox-7f3c", + }), + ); + + // Existing row returned (200, not 201) — no duplicate assessment or trend point. + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { assessment: { id: string } }; + }; + expect(body.data.assessment.id).toBe("mha_dup"); + expect(prisma.mentalHealthAssessment.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: "user-1", externalId: "outbox-7f3c" }, + }), + ); + expect(prisma.mentalHealthAssessment.create).not.toHaveBeenCalled(); + expect(prisma.measurement.create).not.toHaveBeenCalled(); + }); + + it("persists client provenance + externalId on a first write", async () => { + vi.mocked(prisma.mentalHealthAssessment.create).mockResolvedValue({ + id: "mha_3", + instrument: "GAD7", + locale: "en", + version: "standard", + totalScore: 3, + severityBand: "mild", + item9Flagged: false, + crisisShownAt: null, + takenAt: new Date("2026-06-28T00:00:00.000Z"), + createdAt: new Date("2026-06-28T00:00:00.000Z"), + } as never); + + const res = await callPost( + makeReq({ + instrument: "GAD7", + items: [1, 1, 1, 0, 0, 0, 0], + source: "IOS", + externalId: "outbox-aa01", + }), + ); + expect(res.status).toBe(201); + + const createArg = vi.mocked(prisma.mentalHealthAssessment.create).mock + .calls[0][0] as { data: { source: string; externalId: string | null } }; + expect(createArg.data.source).toBe("IOS"); + expect(createArg.data.externalId).toBe("outbox-aa01"); + }); + + it("rejects a wrong item count with 422 and never writes", async () => { + const res = await callPost( + makeReq({ instrument: "PHQ9", items: [1, 2, 3] }), // 3 ≠ 9 + ); + expect(res.status).toBe(422); + expect(prisma.mentalHealthAssessment.create).not.toHaveBeenCalled(); + }); + + it("returns the module 403 when the mental-health module is off", async () => { + const { apiError } = await import("@/lib/api-response"); + vi.mocked(requireModuleEnabled).mockResolvedValue({ + enabled: false, + response: apiError("disabled", 403, { errorCode: "module.disabled" }), + }); + const res = await callPost( + makeReq({ instrument: "PHQ9", items: [0, 0, 0, 0, 0, 0, 0, 0, 0] }), + ); + expect(res.status).toBe(403); + expect(prisma.mentalHealthAssessment.create).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/mental-health/assessments/route.ts b/src/app/api/mental-health/assessments/route.ts new file mode 100644 index 000000000..315e547b0 --- /dev/null +++ b/src/app/api/mental-health/assessments/route.ts @@ -0,0 +1,349 @@ +/** + * v1.25 — opt-in mental-health screener assessments (PHQ-9 / GAD-7). + * + * POST records one completed administration: the per-item answers are encrypted + * into a single AES-256-GCM Bytes blob (never logged, never returned by + * default), and the server-authoritative total + severity band + item-9 safety + * flag are denormalised for cheap history reads. A matching PHQ9_SCORE / + * GAD7_SCORE Measurement row is written so the score trend rides the existing + * chart/rollup infra — the trend, never the item content. + * + * SAFETY: on any non-zero PHQ-9 item 9, the response carries the calm, + * locale-aware crisis-resource set and stamps `crisisShownAt`. No clinical + * interpretation, no AI ingestion of item content, no third-party alerting. + * + * GET lists the caller's assessments (totals + bands + flags only — item-level + * answers are excluded from this surface by construction). + */ +import { NextRequest } from "next/server"; +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + returnAllZodIssues, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { withIdempotency } from "@/lib/idempotency"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { + createAssessmentSchema, + listAssessmentsSchema, +} from "@/lib/validations/mental-health"; +import { + INSTRUMENTS, + INSTRUMENT_MEASUREMENT_TYPE, + isSafetyFlagged, + scoreTotal, + severityBand, + type InstrumentId, +} from "@/lib/mental-health/instruments"; +import { crisisResourcesForLocale } from "@/lib/mental-health/crisis-resources"; +import type { + MeasurementType, + MeasurementSource, +} from "@/generated/prisma/client"; + +function shapeRow(row: { + id: string; + instrument: string; + locale: string; + version: string; + totalScore: number; + severityBand: string; + item9Flagged: boolean; + crisisShownAt: Date | null; + takenAt: Date; + createdAt: Date; +}) { + return { + id: row.id, + instrument: row.instrument, + locale: row.locale, + version: row.version, + totalScore: row.totalScore, + severityBand: row.severityBand, + item9Flagged: row.item9Flagged, + crisisShownAt: row.crisisShownAt?.toISOString() ?? null, + takenAt: row.takenAt.toISOString(), + createdAt: row.createdAt.toISOString(), + }; +} + +// Idempotent replay: return an already-recorded administration in the same +// shape a fresh create returns. The crisis set is re-derived from the stored +// item-9 flag + presented locale — no item content is decrypted. 200 (not 201) +// signals "already exists" to the client. +function respondExisting(row: Parameters[0]): Response { + const def = INSTRUMENTS[row.instrument as InstrumentId]; + const crisis = row.item9Flagged ? crisisResourcesForLocale(row.locale) : null; + return apiSuccess( + { + assessment: shapeRow(row), + actionThreshold: def.actionThreshold, + crisis, + }, + 200, + ); +} + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + // Opt-in module (default OFF): the screener history is unreachable until the + // account turns the mental-health module on. Enforced server-side even for a + // valid Bearer token (the nav entry / page redirect are UX-only). + const gate = await requireModuleEnabled(user.id, "mentalHealth"); + if (!gate.enabled) return gate.response; + const params = Object.fromEntries(request.nextUrl.searchParams); + const parsed = listAssessmentsSchema.safeParse(params); + if (!parsed.success) { + annotate({ + action: { name: "mental-health.list.validation-failed" }, + meta: { issue_count: sanitiseZodIssues(parsed.error.issues).length }, + }); + return returnAllZodIssues(parsed.error, 422); + } + const { instrument, limit, offset } = parsed.data; + const rows = await prisma.mentalHealthAssessment.findMany({ + where: { + userId: user.id, + deletedAt: null, + ...(instrument ? { instrument } : {}), + }, + orderBy: { takenAt: "desc" }, + take: limit ?? 100, + skip: offset ?? 0, + select: { + id: true, + instrument: true, + locale: true, + version: true, + totalScore: true, + severityBand: true, + item9Flagged: true, + crisisShownAt: true, + takenAt: true, + createdAt: true, + }, + }); + annotate({ + action: { name: "mental-health.list" }, + meta: { count: rows.length }, + }); + return apiSuccess({ assessments: rows.map(shapeRow) }); +}); + +// `withIdempotency` lets a double-tap / retry re-send the same +// `Idempotency-Key` without minting a duplicate assessment row AND a duplicate +// `*_SCORE` Measurement row that would skew the trend (the allergy / labs +// create precedent). +export const POST = apiHandler(withIdempotency<[NextRequest]>(postAssessment)); + +async function postAssessment(request: NextRequest): Promise { + const { user } = await requireAuth(); + + // Opt-in module (default OFF) — same gate as GET. + const gate = await requireModuleEnabled(user.id, "mentalHealth"); + if (!gate.enabled) return gate.response; + + // Write rate-limit: a screener is a deliberate, infrequent action, so the + // bucket is tight — it only caps a double-tap / scripted loop minting + // duplicate trend points (the recall window is 2 weeks; 30/hour is generous). + const rl = await checkRateLimit( + `mental-health-create:${user.id}`, + 30, + 60 * 60 * 1000, + ); + if (!rl.allowed) { + const res = apiError( + "Too many check-ins. Please wait before submitting another.", + 429, + { errorCode: "mentalHealth.rateLimited" }, + ); + for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { + res.headers.set(k, v); + } + return res; + } + + // Bounded body read BEFORE validation (the item answers are small; cap so an + // unbounded body can never reach `request.json()`). Destructure the + // `{ data, error }` envelope — passing the wrapper to `safeParse` would never + // match the schema, which is what left this route dead-on-arrival. + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 8 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = createAssessmentSchema.safeParse(body); + if (!parsed.success) { + annotate({ + action: { name: "mental-health.create.validation-failed" }, + meta: { issue_count: sanitiseZodIssues(parsed.error.issues).length }, + }); + return returnAllZodIssues(parsed.error, 422); + } + + const { + instrument, + items, + functionalDifficulty, + takenAt, + tz, + locale, + source, + externalId, + } = parsed.data; + const id = instrument as InstrumentId; + const def = INSTRUMENTS[id]; + + // Durable dedup: a repeat externalId (the native client's outbox replaying a + // queued check-in beyond the 24h idempotency-key window) must return the + // existing administration, never a duplicate row + duplicate trend point. The + // partial unique index on `(user_id, external_id)` is the DB-level backstop + // (migration 0221); this pre-check returns the existing row cleanly without + // relying on a constraint throw. Scoped to the caller — externalId is unique + // per user, not globally. + if (externalId) { + const existing = await prisma.mentalHealthAssessment.findFirst({ + where: { userId: user.id, externalId }, + select: { + id: true, + instrument: true, + locale: true, + version: true, + totalScore: true, + severityBand: true, + item9Flagged: true, + crisisShownAt: true, + takenAt: true, + createdAt: true, + }, + }); + if (existing) return respondExisting(existing); + } + + // Server-authoritative scoring (the client never computes these). + const total = scoreTotal(items); + const band = severityBand(id, total); + const flagged = isSafetyFlagged(id, items); + const presentedLocale = locale ?? user.locale ?? "en"; + const when = takenAt ? new Date(takenAt) : new Date(); + + // Encrypt the per-item answers + the (unscored) functional-impairment follow-up. + const blob = encryptToBytes( + JSON.stringify({ + items, + ...(functionalDifficulty !== undefined ? { functionalDifficulty } : {}), + schema: 1, + }), + ); + + const assessmentSelect = { + id: true, + instrument: true, + locale: true, + version: true, + totalScore: true, + severityBand: true, + item9Flagged: true, + crisisShownAt: true, + takenAt: true, + createdAt: true, + } as const; + + let assessment; + try { + assessment = await prisma.mentalHealthAssessment.create({ + data: { + userId: user.id, + instrument: id, + locale: presentedLocale, + version: "standard", + responsesEncrypted: blob, + totalScore: total, + severityBand: band, + item9Flagged: flagged, + crisisShownAt: flagged ? new Date() : null, + takenAt: when, + tz: tz ?? null, + source, + externalId: externalId ?? null, + }, + select: assessmentSelect, + }); + } catch (err: unknown) { + // Concurrent replay: two requests carrying the same externalId raced past + // the pre-check. The partial unique index (migration 0221) rejects the + // second insert with P2002 — re-fetch the winner and return it idempotently + // rather than surfacing a 500 (the cycle day-log conflict precedent). + if ( + externalId && + typeof err === "object" && + err !== null && + "code" in err && + (err as { code?: unknown }).code === "P2002" + ) { + const winner = await prisma.mentalHealthAssessment.findFirst({ + where: { userId: user.id, externalId }, + select: assessmentSelect, + }); + if (winner) { + annotate({ + action: { name: "mental-health.create.dedup" }, + meta: { instrument: id }, + }); + return respondExisting(winner); + } + } + throw err; + } + + // Read-optimised projection: the total rides a Measurement row so the existing + // chart/rollup infra reads the trend without ever touching item content. The + // externalId anchors the trend point to this administration; duplicate trend + // points are structurally impossible because a replayed externalId returns + // the existing assessment above before reaching this create. `MeasurementSource` + // carries no WEB/IOS member (a screener is questionnaire input, not a device + // sample), so the row stays MANUAL — the WEB/IOS provenance + client + // externalId live on the assessment this row links to. + await prisma.measurement.create({ + data: { + userId: user.id, + type: INSTRUMENT_MEASUREMENT_TYPE[id] as MeasurementType, + value: total, + unit: "score", + source: "MANUAL" as MeasurementSource, + measuredAt: when, + notes: null, + externalId: `assessment:${assessment.id}`, + }, + }); + + await auditLog("mental-health.create", { + userId: user.id, + // The score band is non-sensitive; item answers are NEVER logged. + details: { instrument: id, severityBand: band }, + }); + annotate({ + action: { name: "mental-health.create" }, + meta: { instrument: id, item9_flagged: flagged }, + }); + + // SAFETY: surface the locale-aware crisis resources on a positive item-9. + const crisis = flagged ? crisisResourcesForLocale(presentedLocale) : null; + + return apiSuccess( + { + assessment: shapeRow(assessment), + actionThreshold: def.actionThreshold, + crisis, + }, + 201, + ); +} diff --git a/src/app/api/send/__tests__/route.test.ts b/src/app/api/send/__tests__/route.test.ts index 8ed6df78a..874cca183 100644 --- a/src/app/api/send/__tests__/route.test.ts +++ b/src/app/api/send/__tests__/route.test.ts @@ -1,6 +1,21 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; +// safeFetch's requirePublicHost path runs through undici's own `fetch` +// (version-locked with its dispatcher). Delegate it to the global `fetch` +// stub these tests install so the existing interception still applies. +vi.mock("undici", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetch: (input: unknown, init?: unknown) => + (globalThis.fetch as unknown as (i: unknown, n?: unknown) => unknown)( + input, + init, + ), + }; +}); + vi.mock("@/lib/api-handler", () => ({ apiHandler: unknown>(fn: T) => fn, })); diff --git a/src/app/auth/register/page.tsx b/src/app/auth/register/page.tsx index 4c6173af1..73c1f10a6 100644 --- a/src/app/auth/register/page.tsx +++ b/src/app/auth/register/page.tsx @@ -194,7 +194,13 @@ export default function RegisterPage() {

{t("auth.hasAccount")}{" "} - + {t("auth.login")}

diff --git a/src/app/c/[token]/__tests__/page.test.tsx b/src/app/c/[token]/__tests__/page.test.tsx index 53dacacc1..911a7f956 100644 --- a/src/app/c/[token]/__tests__/page.test.tsx +++ b/src/app/c/[token]/__tests__/page.test.tsx @@ -72,6 +72,15 @@ function isGate(out: unknown): boolean { ); } +/** + * Returns the props the page handed to the gate element. A client component + * boundary can only carry serializable props — issue #374 was a `t` FUNCTION + * crossing it, which throws at render. + */ +function gateProps(out: unknown): Record { + return (out as { props: Record }).props; +} + function resolvedContext() { return { shareLinkId: "link-1", @@ -126,6 +135,23 @@ describe("clinician share page", () => { expect(isGate(out)).toBe(true); expect(resolve).not.toHaveBeenCalled(); expect(loadData).not.toHaveBeenCalled(); + + // Issue #374 contract: every prop crossing the server→client boundary must + // be serializable. NO prop value may be a function (a `t` function here + // throws "Server Components render error"), and the copy must arrive as a + // pre-resolved plain object. + const props = gateProps(out); + for (const value of Object.values(props)) { + expect(typeof value).not.toBe("function"); + } + expect(typeof props.token).toBe("string"); + const strings = props.strings as Record; + expect(strings).toBeTypeOf("object"); + expect(strings).not.toBeNull(); + expect(Object.values(strings).length).toBeGreaterThan(0); + for (const value of Object.values(strings)) { + expect(typeof value).toBe("string"); + } }); it("renders a protected link WITH a valid unlock cookie", async () => { diff --git a/src/app/c/[token]/page.tsx b/src/app/c/[token]/page.tsx index 38633ec14..986836f8a 100644 --- a/src/app/c/[token]/page.tsx +++ b/src/app/c/[token]/page.tsx @@ -82,7 +82,17 @@ export default async function ClinicianSharePage({ if (!verifyUnlockValue(unlockValue, gate.tokenHash)) { const locale = await resolveLocale(); const { t } = getServerTranslator(locale); - return t(key, vars)} token={token} />; + // Resolve the gate copy on the server (it owns the locale) into a plain, + // serializable object. A `t` function cannot cross the RSC boundary into + // the client gate — passing one throws at render (issue #374). + const strings = { + title: t("clinicianView.unlock.title"), + description: t("clinicianView.unlock.description"), + label: t("clinicianView.unlock.label"), + error: t("clinicianView.unlock.error"), + submit: t("clinicianView.unlock.submit"), + }; + return ; } } diff --git a/src/app/c/error.tsx b/src/app/c/error.tsx new file mode 100644 index 000000000..27700b74f --- /dev/null +++ b/src/app/c/error.tsx @@ -0,0 +1,41 @@ +"use client"; + +/** + * Error boundary for the public clinician share route (`/c/`). + * + * The share view carries no app chrome and no session, so a render failure must + * not fall through to the full-app error screen. This renders a clean, locale- + * aware, stack-free message and offers a single retry. No record data, no + * digest, no stack — a probe learns nothing from a failed render. + */ +import { useTranslations } from "@/lib/i18n/context"; +import { Button } from "@/components/ui/button"; + +export default function ClinicianShareError({ + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + const { t } = useTranslations(); + + return ( +
+
+

+ {t("clinicianView.loadError.title")} +

+

+ {t("clinicianView.loadError.description")} +

+ +
+
+ ); +} diff --git a/src/app/coach/page.tsx b/src/app/coach/page.tsx index 85870cf99..c956134fc 100644 --- a/src/app/coach/page.tsx +++ b/src/app/coach/page.tsx @@ -136,7 +136,7 @@ export default function CoachPage() { // `env(safe-area-inset-bottom)`) so the docked composer / Stop control // always clears the nav instead of sitting under it. On `md+` the nav // is hidden, so the page reclaims the full height below the TopBar. - className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6 md:h-[calc(100dvh-4rem)]" + className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-top,0px)-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6 md:h-[calc(100dvh-4rem)]" > {/* `useSearchParams` requires a Suspense boundary so the client-search bailout never opts the whole route out of static optimisation. */} diff --git a/src/app/documents/page.tsx b/src/app/documents/page.tsx new file mode 100644 index 000000000..553c73770 --- /dev/null +++ b/src/app/documents/page.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { Loader2 } from "lucide-react"; + +import { useAuth } from "@/hooks/use-auth"; +import { InboundDocumentsView } from "@/components/documents/inbound-documents-view"; + +/** + * v1.25.0 (W-DOCS-IN) — inbound clinical documents entry. + * + * Born-gated on the resolved `modules.inboundDocuments` flag from + * `GET /api/auth/me` (opt-in / default-off — the per-user opt-in AND the + * operator server-wide kill-switch). An unauthenticated visitor is bounced to + * login; an authenticated account without the module opted in is bounced home + * (the nav entry is already hidden for them, so this only catches a direct URL + * hit). Every `/api/documents/inbound/*` route also enforces the gate + * server-side, so this is a UX redirect, not the security boundary. + */ +export default function DocumentsPage() { + const { user, isLoading, isAuthenticated } = useAuth(); + const router = useRouter(); + + const enabled = user?.modules?.inboundDocuments === true; + + useEffect(() => { + if (isLoading) return; + if (!isAuthenticated) { + router.push("/auth/login"); + } else if (!enabled) { + router.push("/"); + } + }, [isLoading, isAuthenticated, enabled, router]); + + if (isLoading || !isAuthenticated || !enabled) { + return ( +
+ +
+ ); + } + + return ; +} diff --git a/src/app/insights/grip-strength/page.tsx b/src/app/insights/grip-strength/page.tsx new file mode 100644 index 000000000..895fbdedb --- /dev/null +++ b/src/app/insights/grip-strength/page.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { Dumbbell } from "lucide-react"; + +import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; +import { useTranslations } from "@/lib/i18n/context"; + +/** + * v1.25 — `/insights/grip-strength`. + * + * Grip-strength sub-page. Rides the generic HealthKit metric template so the + * signal gets the same detail spine as every other metric (chart, stat strip, + * Coach-read strip, assessment card). The assessment threads the sex-aware + * EWGSOP2 cut-off (men 27 / women 16 kg) via the `norms.ts` table. + */ +export default function InsightsGripStrengthPage() { + const { t } = useTranslations(); + return ( + } + emptyStateCtaType="GRIP_STRENGTH" + coachPrefill={t("insights.gripStrength.coachPrefill")} + /> + ); +} diff --git a/src/app/insights/medications/page.tsx b/src/app/insights/medications/page.tsx index e80f5133e..e95c6a306 100644 --- a/src/app/insights/medications/page.tsx +++ b/src/app/insights/medications/page.tsx @@ -295,6 +295,11 @@ export default function InsightsMedikamentePage() { text={status?.summary ?? null} hasProvider={status?.hasProvider ?? false} updatedAt={status?.updatedAt ?? null} + coachQuestion={t("insights.coach.assessmentPrompt", { + metric: t("insights.medicationCompliance"), + })} + coachScope={{ metric: "compliance" }} + coachAutoSend loading={isStatusLoading} preparing={status?.preparing ?? false} /> diff --git a/src/app/insights/mental-wellbeing/page.tsx b/src/app/insights/mental-wellbeing/page.tsx new file mode 100644 index 000000000..778255ddf --- /dev/null +++ b/src/app/insights/mental-wellbeing/page.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { Loader2 } from "lucide-react"; + +import { useAuth } from "@/hooks/use-auth"; +import { MentalWellbeing } from "@/components/mental-health/mental-wellbeing"; + +/** + * v1.25.0 — `/insights/mental-wellbeing`. + * + * Opt-in PHQ-9 / GAD-7 screener surface, beside mood tracking. The screen owns + * its own questionnaire + result + crisis-resource flow; it deliberately does + * NOT mount the shared Coach-launch assessment card (mental-health item content + * is kept out of the AI Coach by construction). + * + * Born-gated on the resolved `modules.mentalHealth` flag from `GET /api/auth/me` + * (opt-in / default-off — the per-user opt-in AND the operator server-wide + * kill-switch). An unauthenticated visitor is bounced to login; an authenticated + * account without the module opted in is bounced home (the nav entry is already + * hidden for them, so this only catches a direct URL hit). Both assessment + * routes also enforce the gate server-side, so this is a UX redirect, not the + * security boundary. + */ +export default function MentalWellbeingPage() { + const { user, isLoading, isAuthenticated } = useAuth(); + const router = useRouter(); + + const enabled = user?.modules?.mentalHealth === true; + + useEffect(() => { + if (isLoading) return; + if (!isAuthenticated) { + router.push("/auth/login"); + } else if (!enabled) { + router.push("/"); + } + }, [isLoading, isAuthenticated, enabled, router]); + + if (isLoading || !isAuthenticated || !enabled) { + return ( +
+ +
+ ); + } + + return ( +
+ +
+ ); +} diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index 000d31c73..48050141e 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -127,6 +127,33 @@ const RhythmEventsCard = dynamic( })), { ssr: false }, ); +// v1.25 — baseline-drift card. Un-mounts when nothing is drifting; owns its own +// read-only `/api/insights/health-status` query. +const HealthStatusCard = dynamic( + () => + import("@/components/insights/health-status-card").then((mod) => ({ + default: mod.HealthStatusCard, + })), + { ssr: false }, +); +// v1.25 — sleep-breathing screening card. Un-mounts on no data; screening +// signal only, never a diagnosis. +const BreathingScreeningCard = dynamic( + () => + import("@/components/insights/breathing-screening-card").then((mod) => ({ + default: mod.BreathingScreeningCard, + })), + { ssr: false }, +); +// v1.25 — "what changed since your last lab panel" card. Un-mounts with fewer +// than two panels or no shared analyte. +const LabsChangesCard = dynamic( + () => + import("@/components/insights/labs-changes-card").then((mod) => ({ + default: mod.LabsChangesCard, + })), + { ssr: false }, +); // v1.10.3 — "Today's signal" headline card. Promotes COINCIDENT_DEVIATION from // a buried below-the-fold tile to the top-of-overview daily read (the // always-present Apple/WHOOP/Oura pattern). Deferred behind `next/dynamic`; it @@ -447,6 +474,12 @@ export default function InsightsPage() { // briefing is actually rendered (the empty-state path owns the // `noProvider` branch above). noProviderStale={briefingPayload !== null && !advisor.hasProvider} + // v1.25 — the last generation attempt failed. On a held briefing this + // adds a discreet "couldn't refresh — retry" footer hint; on an empty + // card it swaps the generic empty state for an honest "couldn't + // generate" one. Suppressed when no provider is configured (that hint + // owns the surface and a retry would be futile). + generationFailed={advisor.generationFailed && advisor.hasProvider} // v1.21.2 (A4) — server-resolved recall + forward-look off the dashboard // snapshot. Both strings are already localised; null leaves the card's // memory block unrendered. @@ -472,6 +505,9 @@ export default function InsightsPage() { ) : null, signals: , "rhythm-events": , + "health-status": , + breathing: , + "labs-changes": , }; const orderedSectionIds = orderedVisibleSectionIds(layout); diff --git a/src/app/insights/pain/page.tsx b/src/app/insights/pain/page.tsx new file mode 100644 index 000000000..895f25b68 --- /dev/null +++ b/src/app/insights/pain/page.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { Activity } from "lucide-react"; + +import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; +import { useTranslations } from "@/lib/i18n/context"; + +/** + * v1.25 — `/insights/pain`. + * + * Pain (0–10 NRS) sub-page. Patient-reported pain intensity on the validated + * numeric rating scale; rides the generic metric template for its detail + * spine + assessment (no-pain / mild band at 0–3). + */ +export default function InsightsPainPage() { + const { t } = useTranslations(); + return ( + } + emptyStateCtaType="PAIN_NRS" + coachPrefill={t("insights.pain.coachPrefill")} + /> + ); +} diff --git a/src/app/insights/resting-pulse/page.tsx b/src/app/insights/resting-pulse/page.tsx index ccf61598a..95a16af3a 100644 --- a/src/app/insights/resting-pulse/page.tsx +++ b/src/app/insights/resting-pulse/page.tsx @@ -3,6 +3,7 @@ import { Heart } from "lucide-react"; import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; +import { useTranslations } from "@/lib/i18n/context"; /** * v1.4.32 — `/insights/resting-pulse`. @@ -14,6 +15,7 @@ import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page * trend that's not muddied by mid-walk readings. */ export default function InsightsRestingHrPage() { + const { t } = useTranslations(); return ( } emptyStateCtaType={null} - coachPrefill="I haven't logged any resting heart rate yet — why is the daily resting value different from a spot pulse, and how do I improve it?" + coachPrefill={t("insights.restingHr.coachPrefill")} /> ); } diff --git a/src/app/insights/sleep/page.tsx b/src/app/insights/sleep/page.tsx index fda0a981f..cb261e133 100644 --- a/src/app/insights/sleep/page.tsx +++ b/src/app/insights/sleep/page.tsx @@ -104,12 +104,15 @@ export default function InsightsSchlafPage() { the HealthKit scaffold (Moon vs Sparkles). v1.18.1 — moved up to sit DIRECTLY with the duration/architecture chart - group it describes (``), rather than trailing the whole - page. The sleep-quality block below now carries its OWN assessment, so - each distinct chart group reads its own matching text — the canonical - "multiple charts ⇒ multiple texts" rule the recovery page meets. The - card mounts only on this data-bearing branch, so a source-less account - never fetches. + group it describes (``). + + v1.25 — this is now the page's SINGLE assessment. The sleep-quality + block below used to carry a second SLEEP_SCORE assessment that mostly + never generated (no wearable score), leaving a duplicate "connect an AI + provider" / empty card at the foot of the page. That bottom assessment + is gone; the quality block keeps its score tiles only. The card mounts + only on this data-bearing branch, so a source-less account never + fetches. */} ` assessment internally. + — the tiles render stored values. v1.25 — score tiles only; the page's + single assessment is the SLEEP_DURATION card above. */} diff --git a/src/app/insights/waist-to-height/page.tsx b/src/app/insights/waist-to-height/page.tsx new file mode 100644 index 000000000..abae1bbf8 --- /dev/null +++ b/src/app/insights/waist-to-height/page.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { Scaling } from "lucide-react"; + +import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; +import { useTranslations } from "@/lib/i18n/context"; + +/** + * v1.25 — `/insights/waist-to-height`. + * + * Waist-to-height ratio sub-page. The ancestry-robust single number (NICE: + * keep waist under half your height — WHtR ≥ 0.5 flags increased risk). Rides + * the generic metric template for its detail spine + assessment. + */ +export default function InsightsWaistToHeightPage() { + const { t } = useTranslations(); + return ( + } + emptyStateCtaType="WAIST_TO_HEIGHT" + coachPrefill={t("insights.waistToHeight.coachPrefill")} + /> + ); +} diff --git a/src/app/insights/waist/page.tsx b/src/app/insights/waist/page.tsx new file mode 100644 index 000000000..e838df0ee --- /dev/null +++ b/src/app/insights/waist/page.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { Ruler } from "lucide-react"; + +import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; +import { useTranslations } from "@/lib/i18n/context"; + +/** + * v1.25 — `/insights/waist`. + * + * Waist-circumference sub-page. Rides the generic metric template; the + * assessment threads the sex-aware WHO cut-off (men 94 / women 80 cm) via the + * `norms.ts` table. + */ +export default function InsightsWaistPage() { + const { t } = useTranslations(); + return ( + } + emptyStateCtaType="WAIST_CIRCUMFERENCE" + coachPrefill={t("insights.waist.coachPrefill")} + /> + ); +} diff --git a/src/app/insights/weight/page.tsx b/src/app/insights/weight/page.tsx index b621a2600..453a2e3e4 100644 --- a/src/app/insights/weight/page.tsx +++ b/src/app/insights/weight/page.tsx @@ -18,7 +18,6 @@ import { MetricCorrelationCard } from "@/components/insights/metric-correlation- import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge"; import { MetricTargetSummary } from "@/components/insights/metric-target-summary"; import { SubPageShell } from "@/components/insights/sub-page-shell"; -import { TrajectoryForecastCard } from "@/components/insights/derived/trajectory-forecast-card"; import { buildWeightBandsFromHeight } from "@/lib/analytics/value-bands"; /** @@ -115,14 +114,6 @@ export default function InsightsGewichtPage() { - - {/* v1.12.0 — Weight owns the weight × weekday correlation (relocated off the overview onto its metric page). */} diff --git a/src/app/labs/page.tsx b/src/app/labs/page.tsx index b10436be5..359f8d1b2 100644 --- a/src/app/labs/page.tsx +++ b/src/app/labs/page.tsx @@ -6,6 +6,7 @@ import { useRouter } from "next/navigation"; import { useQueryClient } from "@tanstack/react-query"; import { Loader2, Pencil, Plus, ScanLine, Wrench } from "lucide-react"; +import { MedicalDisclaimer } from "@/components/common/medical-disclaimer"; import { LabForm } from "@/components/labs/lab-form"; import { LabList } from "@/components/labs/lab-list"; import { OcrReviewDialog } from "@/components/labs/ocr-review-dialog"; @@ -173,6 +174,11 @@ export default function LabsPage() { ) : null} setDialogOpen(true)} /> + + {/* v1.25 — calm, context-specific framing for lab values: your own + records, ranges vary by lab and person, only a clinician interprets + them. One footer line, not a per-row banner. */} + ); } diff --git a/src/app/medications/page.tsx b/src/app/medications/page.tsx index a65d0182e..6b48c6dbc 100644 --- a/src/app/medications/page.tsx +++ b/src/app/medications/page.tsx @@ -7,6 +7,7 @@ import { useRouter, useSearchParams } from "next/navigation"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; +import { MedicalDisclaimer } from "@/components/common/medical-disclaimer"; import { MedicationWizardDialog } from "@/components/medications/wizard/medication-wizard-dialog"; import { MedicationCard } from "@/components/medications/medication-card"; import { Glp1MedicationCard } from "@/components/medications/glp1-medication-card"; @@ -524,6 +525,12 @@ export default function MedicationsPage() { )} + {/* v1.25 — calm framing for the medications surface: this tracks what + you log and your own adherence, it doesn't assess dosing or + interactions and isn't medical advice. One footer line, neutral — + the med cards themselves stay un-tinted. */} + + {/* v1.5.5 — the per-card kebab triggers used to open inline IntakeImportDialog / ApiEndpointDialog mounts here, but both state slots were only ever set from inside the dialogs diff --git a/src/app/page.tsx b/src/app/page.tsx index e610e4940..4bcb6a2e5 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -205,8 +205,7 @@ export default function DashboardPage() { bpInTargetCount90: snap.extras?.bpInTargetCount90 ?? null, bpInTargetSpanDays90: snap.extras?.bpInTargetSpanDays90 ?? null, glucoseByContext: snap.extras?.glucoseByContext as - | Record - | undefined, + Record | undefined, }; } // v1.4.39.3 — the merge moved to `mergeSlimAndThickAnalytics` so @@ -235,8 +234,7 @@ export default function DashboardPage() { bpInTargetCount90: merged.bpInTargetCount90, bpInTargetSpanDays90: merged.bpInTargetSpanDays90, glucoseByContext: merged.glucoseByContext as - | Record - | undefined, + Record | undefined, }; }, [ snapshotEnabled, diff --git a/src/app/settings/[section]/page.tsx b/src/app/settings/[section]/page.tsx index ad65b2c60..3b8d0b82a 100644 --- a/src/app/settings/[section]/page.tsx +++ b/src/app/settings/[section]/page.tsx @@ -2,6 +2,7 @@ import { notFound } from "next/navigation"; import type { JSX } from "react"; import { AccountSection } from "@/components/settings/account-section"; +import { AnamnesisSection } from "@/components/settings/anamnesis-section"; import { SecuritySection } from "@/components/settings/security-section"; import { AboutSection } from "@/components/settings/about-section"; import { AdvancedSection } from "@/components/settings/advanced-section"; @@ -9,6 +10,7 @@ import { AiSection } from "@/components/settings/ai-section"; import { ApiSection } from "@/components/settings/api-section"; import { CoachSection } from "@/components/settings/coach-section"; import { DashboardSection } from "@/components/settings/dashboard-section"; +import { EnvironmentSection } from "@/components/settings/environment-section"; import { ExportSection } from "@/components/settings/export-section"; import { GesundheitsakteSection } from "@/components/settings/gesundheitsakte-section"; import { IllnessSection } from "@/components/settings/illness-section"; @@ -74,6 +76,8 @@ const SECTION_COMPONENTS: Record< mood: MoodSection, labs: LabsSection, illness: IllnessSection, + environment: EnvironmentSection, + anamnesis: AnamnesisSection, vorsorge: VorsorgeSection, thresholds: ThresholdsSection, api: ApiSection, diff --git a/src/components/__tests__/version-poller.test.ts b/src/components/__tests__/version-poller.test.ts index b079fea7a..fb3a6b938 100644 --- a/src/components/__tests__/version-poller.test.ts +++ b/src/components/__tests__/version-poller.test.ts @@ -1,12 +1,16 @@ /** - * Unit tests for the version-poller reload decision. + * Unit tests for the version-poller decision. * * The guard is keyed on the TARGET (live) version: a repeat mismatch - * against the SAME version is suppressed (no reload loop on a misset - * server), but a SECOND deploy in one session moves the live version - * past the recorded one and the heal flow re-arms. The pre-v1.16.8 - * guard disabled all polling after one attempt, which stranded stale - * shells on multi-deploy days. + * against the SAME version is suppressed (no loop on a misset server), + * but a SECOND deploy in one session moves the live version past the + * recorded one and the flow re-arms. + * + * The runtime behaviour — a detected bump surfaces a Reload toast and + * never reloads the page on its own — lives in `version-poller.tsx`: + * the service-worker eviction + `location.reload()` run only from the + * toast action's `onClick`, so an in-progress form or chat draft is + * never destroyed unprompted. */ import { describe, expect, it } from "vitest"; diff --git a/src/components/admin/_shared.tsx b/src/components/admin/_shared.tsx index da0481bb3..8f20921a2 100644 --- a/src/components/admin/_shared.tsx +++ b/src/components/admin/_shared.tsx @@ -279,11 +279,7 @@ export function useAuthActionLabels(): Record { * mirroring the convention established by `useAuthActionLabels`. */ export type AuthProvider = - | "password" - | "passkey" - | "api_token" - | "withings" - | "unknown"; + "password" | "passkey" | "api_token" | "withings" | "unknown"; export function providerForAction(action: string): AuthProvider { if ( diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 1ec8bbb11..f63244557 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -467,74 +467,134 @@ export function BackupsSection() { /> ) : ( -
- - - - - - - - - - - - {rows.map((row, i) => ( - - - - - - +
+ {/* Wide layout: the canonical table. Hidden below `md`, where the + five columns push Download / Restore off-screen behind a + horizontal scroll; the card list below takes over there. */} +
+
- {t("admin.section.backups.colUser")} - - {t("admin.section.backups.colType")} - - {t("admin.section.backups.colSize")} - - {t("admin.section.backups.colCreatedAt")} - - {t("admin.section.backups.colActions")} -
{row.username} - - {formatBackupType(row.type, t)} - - - {formatBytes(row.sizeBytes, fmt)} - - {fmt.dateTime(row.createdAt)} - -
- - restore.mutate(row)} - /> -
-
+ + + + + + + - ))} - -
+ {t("admin.section.backups.colUser")} + + {t("admin.section.backups.colType")} + + {t("admin.section.backups.colSize")} + + {t("admin.section.backups.colCreatedAt")} + + {t("admin.section.backups.colActions")} +
+ + + {rows.map((row, i) => ( + + {row.username} + + + {formatBackupType(row.type, t)} + + + + {formatBytes(row.sizeBytes, fmt)} + + + {fmt.dateTime(row.createdAt)} + + +
+ + restore.mutate(row)} + /> +
+ + + ))} + + +
+ + {/* Narrow layout: each backup as a self-contained card with the + meta line + a flex-wrapping action row, so Download / Restore + stay reachable on a phone instead of scrolling off-screen. */} +
    + {rows.map((row) => ( +
  • +
    + {row.username} + + {formatBackupType(row.type, t)} + +
    +

    + {t("admin.section.backups.colSize")}:{" "} + {formatBytes(row.sizeBytes, fmt)} ·{" "} + {fmt.dateTime(row.createdAt)} +

    +
    + + restore.mutate(row)} + /> +
    +
  • + ))} +
+

{t("admin.section.backups.retentionLabel", { days: data!.retentionDays, diff --git a/src/components/admin/general-settings-section.tsx b/src/components/admin/general-settings-section.tsx index 8e1dc1011..62cb0d438 100644 --- a/src/components/admin/general-settings-section.tsx +++ b/src/components/admin/general-settings-section.tsx @@ -68,6 +68,7 @@ export function GeneralSettingsSection() {

updateSettings.mutate({ defaultLocale: e.target.value }) @@ -94,6 +95,7 @@ export function GeneralSettingsSection() {

updateSettings.mutate({ defaultUserTimezone: e.target.value }) diff --git a/src/components/charts/__tests__/health-chart-a11y-label.test.tsx b/src/components/charts/__tests__/health-chart-a11y-label.test.tsx new file mode 100644 index 000000000..8a02ddda0 --- /dev/null +++ b/src/components/charts/__tests__/health-chart-a11y-label.test.tsx @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import deMessages from "../../../../messages/de.json"; + +/** + * Chart accessibility — the rendered chart must expose an image role with + * a non-empty data-summary label so a screen reader announces the series + * instead of skipping a silent SVG. Mirrors the empty-window render test: + * mock auth + react-query, render to static markup, assert the DOM. + */ + +function buildData(): unknown[] { + const out: Array<{ date: string; timestamp: number; PULSE: number }> = []; + const base = Date.UTC(2026, 0, 1, 12, 0, 0); + for (let i = 0; i < 10; i++) { + out.push({ + date: `2026-01-${String(i + 1).padStart(2, "0")}`, + timestamp: base + i * 86_400_000, + PULSE: 60 + i, + }); + } + return out; +} + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + isAuthenticated: true, + user: null, + isLoading: false, + }), +})); + +describe(" — accessibility label", () => { + beforeEach(() => { + vi.resetModules(); + }); + + it("wraps the chart in role=img with a non-empty aria-label when data is present", async () => { + const data = buildData(); + + vi.doMock("@tanstack/react-query", () => ({ + useQuery: () => ({ data, isLoading: false }), + useQueryClient: () => ({ + cancelQueries: () => Promise.resolve(), + getQueryData: () => undefined, + setQueryData: () => undefined, + invalidateQueries: () => Promise.resolve(), + }), + useMutation: () => ({ mutate: () => undefined, isPending: false }), + })); + + const { I18nProvider } = await import("@/lib/i18n/context"); + const { HealthChart } = await import("../health-chart"); + + const html = renderToStaticMarkup( + + + , + ); + + expect(html).toContain('role="img"'); + // A role=img wrapper with an empty/absent label is worse than none — + // assert the aria-label attribute carries actual text. + const match = html.match(/role="img"[^>]*aria-label="([^"]*)"/); + expect(match?.[1] ?? "").not.toBe(""); + + vi.doUnmock("@tanstack/react-query"); + }); +}); diff --git a/src/components/charts/compliance-heatmap.tsx b/src/components/charts/compliance-heatmap.tsx index e92a03f48..06071fb69 100644 --- a/src/components/charts/compliance-heatmap.tsx +++ b/src/components/charts/compliance-heatmap.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useMemo, useEffect, useRef } from "react"; -import { useTranslations } from "@/lib/i18n/context"; +import { useTranslations, useFormatters } from "@/lib/i18n/context"; interface DailyData { expected: number; @@ -72,22 +72,17 @@ function getColor(data: DailyData): string { return "var(--dracula-red)"; } -function formatDateDE(dateStr: string): string { - // v1.4.27 B7 / BL-P4-2 — parse the day-key against UTC so the - // formatted tooltip label matches the dateKey computation below - // (which uses `toISOString().slice(0, 10)`). Without the UTC - // anchor an SSR server in a non-Berlin timezone could format the - // tick a day off the dateKey it sits under. - const d = new Date(dateStr + "T00:00:00Z"); - return `${d.getUTCDate()}.${d.getUTCMonth() + 1}.${d.getUTCFullYear()}`; -} - export function ComplianceHeatmap({ dailyCompliance, days = 90, stretch = false, }: ComplianceHeatmapProps) { const { t } = useTranslations(); + const fmt = useFormatters(); + // Day keys are UTC-anchored "YYYY-MM-DD"; format at noon UTC so the + // locale-aware renderer never lands a day off in either direction. + const formatDay = (dateKey: string) => + fmt.date(new Date(`${dateKey}T12:00:00Z`)); const containerRef = useRef(null); const [containerWidth, setContainerWidth] = useState(0); const [tooltip, setTooltip] = useState<{ @@ -230,6 +225,16 @@ export function ComplianceHeatmap({ return { cells: cellList, weeks: col + 1, monthMarkers: markers }; }, [dailyCompliance, days, t]); + const activeDays = cells.filter((cell) => cell.data.expected > 0).length; + const summaryLabel = + cells.length === 0 || activeDays === 0 + ? t("charts.a11y.complianceHeatmapEmpty") + : t("charts.a11y.complianceHeatmap", { + start: formatDay(cells[0].dateKey), + end: formatDay(cells[cells.length - 1].dateKey), + days: activeDays, + }); + const labelWidth = stretch ? 0 : 76; const headerHeight = 18; // v1.4.27 MB7 / CF-10 — clamp the stretch-branch adaptive cell to the @@ -268,6 +273,8 @@ export function ComplianceHeatmap({ {tooltip.text} diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 6ea6cff27..7d667dc6b 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -494,8 +494,7 @@ export function resolveVerticalMarkerPositions( export function resolveAnnotationPositions( annotations: - | Array<{ date: string; label: string; color: string }> - | undefined, + Array<{ date: string; label: string; color: string }> | undefined, chartData: Array<{ timestamp: number }> | undefined, ): ResolvedAnnotation[] { if (!annotations || !chartData || chartData.length === 0) return []; @@ -1386,6 +1385,51 @@ export function HealthChart({ const formatTooltipValue = (value: number) => fmt.number(value, 1); + // Accessibility — a concise spoken summary of the primary series so a screen + // reader announces the shape of the chart (range, latest value, direction) + // instead of an unlabelled graphic. Mirrors the visible-range stats the + // chart already holds; falls back to the raw min/max when the stat strip + // is suppressed (mini mode). + const primaryType = types[0]; + const chartAriaLabel = useMemo(() => { + const metric = getTypeLabel(primaryType, valueMode, t); + const source = chartDataWithCompare ?? chartData; + const values = (source ?? []) + .map((point) => point[primaryType]) + .filter((v): v is number => typeof v === "number" && Number.isFinite(v)); + if (values.length === 0) { + return t("charts.a11y.noData", { metric }); + } + const stats = visibleStatsByType?.[primaryType]; + const minVal = stats?.min ?? Math.min(...values); + const maxVal = stats?.max ?? Math.max(...values); + const latestVal = values[values.length - 1]; + const firstVal = values[0]; + const epsilon = Math.max(Math.abs(maxVal - minVal) * 0.02, 0.01); + const delta = latestVal - firstVal; + const trend = + delta > epsilon + ? t("charts.a11y.trendUp") + : delta < -epsilon + ? t("charts.a11y.trendDown") + : t("charts.a11y.trendFlat"); + return t("charts.a11y.summary", { + metric, + min: fmt.number(minVal, 1), + max: fmt.number(maxVal, 1), + latest: fmt.number(latestVal, 1), + trend, + }); + }, [ + primaryType, + valueMode, + t, + chartDataWithCompare, + chartData, + visibleStatsByType, + fmt, + ]); + const trendInfo = useMemo(() => { if (!showTrend || !chartData?.length) return []; @@ -1734,7 +1778,11 @@ export function HealthChart({ ) : null} -
+
`${fmt.integer(value)} %`; const animationsEnabled = !prefersReducedMotion(); + // Accessibility — a concise spoken summary of the compliance series so a + // screen reader announces the adherence range, the latest day and the + // direction rather than an unlabelled graphic. Reuses the same 7-day trend + // the chip surfaces; falls back to a first-vs-last comparison when the + // window is too short for the trend helper. + const complianceAriaLabel = ((): string => { + const metric = displayTitle; + const rates = chartData + .map((p) => p.rate) + .filter((v): v is number => typeof v === "number" && Number.isFinite(v)); + if (rates.length === 0) { + return t("charts.a11y.noData", { metric }); + } + const pct = (v: number) => `${fmt.integer(v)} %`; + const minVal = Math.min(...rates); + const maxVal = Math.max(...rates); + const latestVal = rates[rates.length - 1]; + let trendWord: string; + if (trend && trend.direction !== "stable") { + trendWord = + trend.direction === "up" + ? t("charts.a11y.trendUp") + : t("charts.a11y.trendDown"); + } else { + const delta = latestVal - rates[0]; + trendWord = + delta > 1 + ? t("charts.a11y.trendUp") + : delta < -1 + ? t("charts.a11y.trendDown") + : t("charts.a11y.trendFlat"); + } + return t("charts.a11y.summary", { + metric, + min: pct(minVal), + max: pct(maxVal), + latest: pct(latestVal), + trend: trendWord, + }); + })(); + // Empty-state guard: if the user has zero scheduled doses across the // whole window we render the title + a "no data" hint, mirroring how // the other charts handle the empty case (HealthChart returns null @@ -475,7 +516,11 @@ export function MedicationComplianceChart({ /> ) ) : ( -
+
{ + const metric = displayTitle; + const scores = (chartData ?? []) + .map((d) => d.score) + .filter((v): v is number => typeof v === "number" && Number.isFinite(v)); + if (scores.length === 0) { + return t("charts.a11y.noData", { metric }); + } + const fmtScore = (v: number) => String(Math.round(v * 10) / 10); + const minVal = Math.min(...scores); + const maxVal = Math.max(...scores); + const latestVal = scores[scores.length - 1]; + const firstVal = scores[0]; + const epsilon = 0.1; + const delta = latestVal - firstVal; + const trend = + delta > epsilon + ? t("charts.a11y.trendUp") + : delta < -epsilon + ? t("charts.a11y.trendDown") + : t("charts.a11y.trendFlat"); + return t("charts.a11y.summary", { + metric, + min: fmtScore(minVal), + max: fmtScore(maxVal), + latest: fmtScore(latestVal), + trend, + }); + })(); + const chartBody = isLoading ? ( // v1.16.0 — height-matched skeleton band instead of the former // `h-48` spinner box: the loading state occupies the same box the @@ -680,6 +713,8 @@ export function MoodChart({ ? "h-[var(--chart-height,140px)]" : "h-[var(--chart-height,200px)] md:h-[var(--chart-height-md,220px)]" } touch-pan-y`} + role="img" + aria-label={moodAriaLabel} > + fmt.date(new Date(`${dateKey}T12:00:00Z`)); const containerRef = useRef(null); const [containerWidth, setContainerWidth] = useState(0); const [tooltip, setTooltip] = useState<{ @@ -185,6 +185,16 @@ export function MoodHeatmap({ return { cells: cellList, weeks: col + 1, monthMarkers: markers }; }, [cellData, days, t]); + const loggedDays = cells.filter((cell) => cell.cell !== null).length; + const summaryLabel = + cells.length === 0 || loggedDays === 0 + ? t("charts.a11y.moodHeatmapEmpty") + : t("charts.a11y.moodHeatmap", { + start: formatDay(cells[0].dateKey), + end: formatDay(cells[cells.length - 1].dateKey), + days: loggedDays, + }); + const labelWidth = stretch ? 0 : 76; const headerHeight = 18; const cellSize = @@ -214,6 +224,8 @@ export function MoodHeatmap({ { const buildText = (): string => { if (!cell.cell) { - return `${formatDateDE(cell.dateKey)}: ${t("insights.mood.heatmapNoEntry")}`; + return `${formatDay(cell.dateKey)}: ${t("insights.mood.heatmapNoEntry")}`; } const labelKey = moodLabelKeyForScore( Math.round(cell.cell.score), ); const moodLabel = labelKey ? t(labelKey) : ""; - return `${formatDateDE(cell.dateKey)}: ${cell.cell.score.toFixed(1)}${moodLabel ? ` · ${moodLabel}` : ""}`; + return `${formatDay(cell.dateKey)}: ${cell.cell.score.toFixed(1)}${moodLabel ? ` · ${moodLabel}` : ""}`; }; return ( {tooltip.text}
diff --git a/src/components/charts/scatter-correlation-chart.tsx b/src/components/charts/scatter-correlation-chart.tsx index 1f6f9f3ed..a75d3d7ac 100644 --- a/src/components/charts/scatter-correlation-chart.tsx +++ b/src/components/charts/scatter-correlation-chart.tsx @@ -10,6 +10,7 @@ import { XAxis, YAxis, } from "recharts"; +import { useTranslations } from "@/lib/i18n/context"; /** * Self-contained correlation scatter chart used by /insights. @@ -82,6 +83,22 @@ export function ScatterCorrelationChart>({ tooltipFormatter, height = 250, }: ScatterCorrelationChartProps) { + const { t } = useTranslations(); + // Accessibility — a concise spoken summary so a screen reader announces + // what the scatter relates and how many points it plots rather than an + // unlabelled graphic. + const count = data?.length ?? 0; + const summary = + count === 0 + ? t("charts.a11y.scatterNoData", { + xLabel: xAxis.name, + yLabel: yAxis.name, + }) + : t("charts.a11y.scatterSummary", { + count, + xLabel: xAxis.name, + yLabel: yAxis.name, + }); // v1.4.27 MB7 / CF-37 — drop the fixed pixel height in favour of a // responsive aspect-ratio on mobile so the chart never compresses // below readable axes. The `aspect-square` ratio at `>({ className="aspect-square min-h-[180px] touch-pan-y sm:aspect-[3/2] sm:h-auto" style={{ height: undefined }} data-explicit-height={height} + role="img" + aria-label={summary} > diff --git a/src/components/clinician/clinician-view.tsx b/src/components/clinician/clinician-view.tsx index 2b06593a2..1850a2b64 100644 --- a/src/components/clinician/clinician-view.tsx +++ b/src/components/clinician/clinician-view.tsx @@ -10,7 +10,7 @@ * a FENCED, muted wellness card carrying the load-bearing "descriptive, not a * clinical assessment / not a diagnosis" disclaimer. */ -import { DOCTOR_REPORT_TYPE_LABEL_KEYS } from "@/lib/doctor-report-pdf-core"; +import { DOCTOR_REPORT_TYPE_LABEL_KEYS } from "@/lib/doctor-report/type-label-keys"; import type { DoctorReportData } from "@/lib/doctor-report-data"; import type { DoctorReportPrefs } from "@/lib/validations/doctor-report-prefs"; diff --git a/src/components/clinician/share-unlock-gate.tsx b/src/components/clinician/share-unlock-gate.tsx index a4f613e27..36547d00d 100644 --- a/src/components/clinician/share-unlock-gate.tsx +++ b/src/components/clinician/share-unlock-gate.tsx @@ -25,18 +25,26 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { apiPost } from "@/lib/api/api-fetch"; -type Translate = ( - key: string, - vars?: Record, -) => string; +/** + * Plain, serializable copy for the gate. Resolved on the server (which owns the + * locale) and passed across the RSC boundary as data — never a `t` function, as + * functions cannot be serialized to a client component. + */ +export interface ShareUnlockGateStrings { + title: string; + description: string; + label: string; + error: string; + submit: string; +} interface ShareUnlockGateProps { - t: Translate; + strings: ShareUnlockGateStrings; /** The raw `hls_` token from the path — used only to build the verify URL. */ token: string; } -export function ShareUnlockGate({ t, token }: ShareUnlockGateProps) { +export function ShareUnlockGate({ strings, token }: ShareUnlockGateProps) { const [passphrase, setPassphrase] = useState(""); const [pending, setPending] = useState(false); const [error, setError] = useState(false); @@ -86,18 +94,12 @@ export function ShareUnlockGate({ t, token }: ShareUnlockGateProps) { }} >
-

- {t("clinicianView.unlock.title")} -

-

- {t("clinicianView.unlock.description")} -

+

{strings.title}

+

{strings.description}

- + - {t("clinicianView.unlock.error")} + {strings.error}

)} @@ -122,7 +124,7 @@ export function ShareUnlockGate({ t, token }: ShareUnlockGateProps) { disabled={pending || !passphrase.trim()} className="min-h-11 w-full sm:min-h-9" > - {t("clinicianView.unlock.submit")} + {strings.submit} diff --git a/src/components/common/medical-disclaimer.tsx b/src/components/common/medical-disclaimer.tsx new file mode 100644 index 000000000..ba27663cf --- /dev/null +++ b/src/components/common/medical-disclaimer.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { useTranslations } from "@/lib/i18n/context"; + +/** + * v1.25 — the one place the app states what it is and is not. + * + * HealthLog's self-description standard is layered, not a wall of warnings: + * a single intended-purpose statement plus calm, context-specific notes that + * sit once on the surface they belong to. Every string is plain React text — + * no markdown renderer, in line with the project's XSS posture (Coach, + * briefing and insights all render as text children). + * + * The copy lives under the `selfDescription` i18n namespace so every locale + * carries the same set; pick the note that fits the surface: + * + * - `intendedPurpose` — the canonical "what this is / is not, not a medical + * device" line. Reuse verbatim; do not paraphrase per surface. + * - `coach` — the Coach standing line (works from your data, not a doctor). + * - `insights` — descriptive-patterns framing for insights + the briefing. + * - `labs` — your own records, ranges vary, only a clinician interprets. + * - `medications` — tracks what you log, not dosing/interaction advice. + * - `emergency` — the red-flag interstitial note (contact emergency services). + * - `dataPosture` — self-hosted, data stays on your instance, BYOK / local AI. + * + * The acknowledged onboarding disclaimer (`onboarding.disclaimer.*`) and the + * legal medical-device boundary on the public privacy page remain the standing + * floor; these notes are the gentle, in-context layer above it. + */ +export type DisclaimerVariant = + | "intendedPurpose" + | "coach" + | "insights" + | "labs" + | "medications" + | "emergency" + | "dataPosture"; + +const VARIANT_KEY: Record = { + intendedPurpose: "selfDescription.intendedPurpose", + coach: "selfDescription.coach", + insights: "selfDescription.insights", + labs: "selfDescription.labs", + medications: "selfDescription.medications", + emergency: "selfDescription.emergency", + dataPosture: "selfDescription.dataPosture", +}; + +export interface MedicalDisclaimerProps { + variant: DisclaimerVariant; + className?: string; +} + +/** + * A calm, muted, plain-text disclaimer line. Defaults to a small, + * unobtrusive footnote treatment; pass `className` to tune spacing for the + * surface. Keep it to one instance per surface — the standard is layered, + * not repeated. + */ +export function MedicalDisclaimer({ + variant, + className, +}: MedicalDisclaimerProps) { + const { t } = useTranslations(); + return ( +

+ {t(VARIANT_KEY[variant])} +

+ ); +} diff --git a/src/components/cycle/cycle-phase-crosstab.tsx b/src/components/cycle/cycle-phase-crosstab.tsx index 69c9bbe07..f2badb1aa 100644 --- a/src/components/cycle/cycle-phase-crosstab.tsx +++ b/src/components/cycle/cycle-phase-crosstab.tsx @@ -19,14 +19,7 @@ import { cn } from "@/lib/utils"; */ export type CyclePhaseCrosstabDisplay = - | "hours" - | "steps" - | "bpm" - | "ms" - | "kg" - | "celsius" - | "glucose" - | "mood"; + "hours" | "steps" | "bpm" | "ms" | "kg" | "celsius" | "glucose" | "mood"; export type CyclePhaseCrosstabConfidence = "low" | "medium" | "high"; diff --git a/src/components/cycle/log-day-sheet.tsx b/src/components/cycle/log-day-sheet.tsx index 00a78ba19..9a0c4797e 100644 --- a/src/components/cycle/log-day-sheet.tsx +++ b/src/components/cycle/log-day-sheet.tsx @@ -423,7 +423,7 @@ function Chip({ aria-pressed={active} onClick={onClick} className={cn( - "focus-visible:ring-ring/50 rounded-full border px-3 py-1.5 text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none", + "focus-visible:ring-ring/50 inline-flex min-h-11 items-center rounded-full border px-3 py-1.5 text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none sm:min-h-8", active ? "border-primary bg-primary/10 text-primary" : "border-border text-foreground hover:bg-accent", @@ -1225,7 +1225,7 @@ function SymptomChip({ onClick={handleRemove} disabled={deleteCustom.isPending} aria-label={t("cycle.symptom.custom.remove", { label })} - className="border-border bg-background text-muted-foreground hover:text-destructive hover:border-destructive focus-visible:ring-ring/50 -ml-1.5 grid size-4 shrink-0 place-items-center rounded-full border transition-colors focus-visible:ring-2 focus-visible:outline-none" + className="border-border bg-background text-muted-foreground hover:text-destructive hover:border-destructive focus-visible:ring-ring/50 relative -ml-1.5 grid size-4 shrink-0 place-items-center rounded-full border transition-colors before:absolute before:-inset-2 before:content-[''] focus-visible:ring-2 focus-visible:outline-none" >
+ ) : isError ? ( +
+ {t("illness.listLoadError")} + +
) : hasEpisodes ? ( <> {/* Retrospective summary sits above the list so a long history diff --git a/src/components/illness/log-day-sheet.tsx b/src/components/illness/log-day-sheet.tsx index 1f6a740e8..0afacddc7 100644 --- a/src/components/illness/log-day-sheet.tsx +++ b/src/components/illness/log-day-sheet.tsx @@ -60,7 +60,7 @@ function Chip({ aria-pressed={active} onClick={onClick} className={cn( - "rounded-full border px-3 py-1.5 text-sm transition-colors", + "inline-flex min-h-11 items-center rounded-full border px-3 py-1.5 text-sm transition-colors sm:min-h-8", active ? "border-primary bg-primary/10 text-foreground" : "border-border text-muted-foreground hover:bg-muted", @@ -95,7 +95,7 @@ function SymptomChip({ aria-pressed={active} onClick={onToggle} className={cn( - "flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm transition-colors", + "flex min-h-11 items-center gap-2 rounded-full border px-3 py-1.5 text-sm transition-colors sm:min-h-8", active ? "border-primary bg-primary/10 text-foreground" : "border-border text-muted-foreground hover:bg-muted", @@ -117,7 +117,7 @@ function SymptomChip({ aria-pressed={severity === value} onClick={() => onSeverity(value)} className={cn( - "h-6 w-6 rounded-md border text-xs", + "grid h-11 w-11 place-items-center rounded-md border text-xs sm:h-8 sm:w-8", severity === value ? "border-primary bg-primary/10 text-foreground" : "border-border text-muted-foreground hover:bg-muted", diff --git a/src/components/illness/types.ts b/src/components/illness/types.ts index 4f87580e5..64967cb5a 100644 --- a/src/components/illness/types.ts +++ b/src/components/illness/types.ts @@ -13,10 +13,7 @@ export type IllnessType = | "OTHER"; export type IllnessLifecycle = - | "ACUTE" - | "CHRONIC_ONGOING" - | "RECURRING" - | "FLARE"; + "ACUTE" | "CHRONIC_ONGOING" | "RECURRING" | "FLARE"; export interface IllnessSymptomSelection { key: string; diff --git a/src/components/insights/__tests__/daily-briefing.test.tsx b/src/components/insights/__tests__/daily-briefing.test.tsx index a7fa34620..5b1903f7e 100644 --- a/src/components/insights/__tests__/daily-briefing.test.tsx +++ b/src/components/insights/__tests__/daily-briefing.test.tsx @@ -238,4 +238,55 @@ describe("", () => { expect(html).toContain('data-slot="daily-briefing-findings"'); expect(html).not.toContain('data-slot="daily-briefing-no-provider"'); }); + + // v1.25 — generation-failed affordances. The briefing keeps its last good + // text; the card adds an honest "couldn't refresh" footer hint on a held + // briefing and a "couldn't generate — retry" empty state when there is none. + it("renders the refresh-failed footer hint on a held briefing", () => { + const html = render( + {}} + generationFailed + />, + ); + expect(html).toContain('data-slot="daily-briefing-refresh-failed"'); + // The rendered apostrophe is HTML-escaped; match the unambiguous tail. + expect(html).toContain("refresh the briefing"); + expect(html).toContain('data-slot="daily-briefing-refresh-failed-retry"'); + }); + + it("does NOT render the refresh-failed hint when generation did not fail", () => { + const html = render( + {}} />, + ); + expect(html).not.toContain('data-slot="daily-briefing-refresh-failed"'); + }); + + it("suppresses the refresh-failed hint when no provider is connected", () => { + const html = render( + {}} + generationFailed + noProviderStale + />, + ); + expect(html).not.toContain('data-slot="daily-briefing-refresh-failed"'); + expect(html).toContain('data-slot="daily-briefing-stale-no-provider"'); + }); + + it("renders the 'couldn't generate' empty state with a retry CTA when failed and empty", () => { + const html = render( + {}} + generationFailed + />, + ); + // The rendered apostrophe is HTML-escaped; match the unambiguous tail. + expect(html).toContain("generate the briefing"); + expect(html).toMatch(/data-slot="daily-briefing-empty-cta"/); + expect(html).toContain("Retry"); + }); }); diff --git a/src/components/insights/ask-coach-action.tsx b/src/components/insights/ask-coach-action.tsx index 1200ab309..c0e038126 100644 --- a/src/components/insights/ask-coach-action.tsx +++ b/src/components/insights/ask-coach-action.tsx @@ -3,6 +3,12 @@ import { Sparkles } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; import { @@ -44,6 +50,12 @@ export interface AskCoachActionProps { * briefing), which read better against the default all-source snapshot. */ scope?: CoachLaunchScope; + /** + * Auto-send the seeded question as the conversation's first turn instead + * of only seeding the composer. The assessment hand-off sets this so the + * Coach answers immediately; defaults to false (seed-only). + */ + autoSend?: boolean; /** Optional visible-label override; defaults to the shared CTA copy. */ label?: string; /** className passthrough for per-card alignment. */ @@ -53,6 +65,7 @@ export interface AskCoachActionProps { export function AskCoachAction({ question, scope, + autoSend, label, className, }: AskCoachActionProps) { @@ -76,7 +89,7 @@ export function AskCoachAction({ variant="ghost" size="sm" data-slot="ask-coach-action" - onClick={() => launch.askCoach(question, scope)} + onClick={() => launch.askCoach(question, scope, autoSend)} className={cn( // Discreet: muted text, hover lifts to foreground, no fill. Tight // height so it sits as a quiet footer link, not a primary CTA. @@ -89,3 +102,63 @@ export function AskCoachAction({ ); } + +/** + * v1.25 — icon-only Coach hand-off pinned to the TOP-RIGHT of an assessment + * card header. Same launch + gating contract as ``, but no + * visible text label: the affordance is a single ghost icon button with an + * accessible name and a tooltip carrying the same copy (a11y — an icon button + * must announce its purpose). + * + * Self-gates on the same triple every Coach entry point uses (provider mounted + * in the tree + operator flag + per-user opt-out), so the card never paints a + * dead control. The assessment card only mounts it on the populated branch, + * where the provider is known good — the "no advertised-but-broken control" + * rule holds end-to-end. + */ +export function AskCoachIconButton({ + question, + scope, + autoSend, + label, + className, +}: AskCoachActionProps) { + const { t } = useTranslations(); + const launch = useCoachLaunch(); + const flags = useFeatureFlags(); + const disableCoach = useDisableCoach(); + + if (!launch) return null; + if (!flags.coach) return null; + if (disableCoach) return null; + + const accessibleLabel = label ?? t("insights.coach.askAboutThis"); + + return ( + + + + + + {accessibleLabel} + + + ); +} diff --git a/src/components/insights/breathing-screening-card.tsx b/src/components/insights/breathing-screening-card.tsx new file mode 100644 index 000000000..0da2d22fc --- /dev/null +++ b/src/components/insights/breathing-screening-card.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { Wind } from "lucide-react"; + +import { useTranslations } from "@/lib/i18n/context"; +import { useAuth } from "@/hooks/use-auth"; +import { cn } from "@/lib/utils"; +import { apiGet } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; +import { SectionHeading } from "@/components/insights/section-heading"; +import type { + BreathingClassification, + BreathingTrend, +} from "@/lib/insights/breathing-screening"; + +/** + * v1.25 — sleep-breathing screening card. + * + * A calm read off the read-only `/api/insights/breathing-screening` route: the + * device's own classification, the recent index trend, and the device-flagged + * event count. It un-mounts when there is no data (`present === false`). Every + * render carries the mandatory screening-only disclaimer — this is a SCREENING + * SIGNAL, not a diagnosis. Neutral tone, no chart, no red. + */ + +interface BreathingScreeningResponse { + present: boolean; + nights: number; + recentMeanIndex: number | null; + trend: BreathingTrend; + eventCount: number; + classification: BreathingClassification; + generatedAt: string; +} + +interface BreathingScreeningCardProps { + enabled?: boolean; + className?: string; +} + +export function BreathingScreeningCard({ + enabled = true, + className, +}: BreathingScreeningCardProps) { + const { t } = useTranslations(); + const { isAuthenticated } = useAuth(); + + const { data } = useQuery({ + queryKey: queryKeys.insightsBreathingScreening(), + queryFn: () => + apiGet("/api/insights/breathing-screening"), + enabled: enabled && isAuthenticated, + staleTime: 60_000, + }); + + if (!data || data.present === false) return null; + + const classificationLabel = + data.classification === "elevated" + ? t("insights.breathingScreening.classificationElevated") + : data.classification === "not-elevated" + ? t("insights.breathingScreening.classificationNotElevated") + : null; + + const trendLabel = + data.trend === "up" + ? t("insights.breathingScreening.trendUp") + : data.trend === "down" + ? t("insights.breathingScreening.trendDown") + : data.trend === "stable" + ? t("insights.breathingScreening.trendStable") + : null; + + return ( +
+ +
+ {classificationLabel ? ( +

+ {classificationLabel} +

+ ) : null} + +

+ {t("insights.breathingScreening.nights", { count: data.nights })} + {data.recentMeanIndex != null + ? ` · ${t("insights.breathingScreening.indexLine", { + value: data.recentMeanIndex, + })}` + : ""} +

+ + {trendLabel ? ( +

+ {trendLabel} +

+ ) : null} + + {data.eventCount > 0 ? ( +

+ {t("insights.breathingScreening.eventsFlagged", { + count: data.eventCount, + })} +

+ ) : null} + +

+ {t("insights.breathingScreening.disclaimer")} +

+
+
+ ); +} diff --git a/src/components/insights/coach-panel/__tests__/coach-input.test.tsx b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx index 088e7d0f5..bdcb5fc89 100644 --- a/src/components/insights/coach-panel/__tests__/coach-input.test.tsx +++ b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx @@ -24,21 +24,16 @@ describe("", () => { expect(html).not.toContain("Coach replies are generated"); }); - it("renders the mic disabled with an unsupported tooltip in SSR markup", () => { - // v1.18.10 (W4) — the mic always renders so the affordance stays - // discoverable. SSR (and any browser without the Web Speech API) shows - // it DISABLED with an explanatory tooltip rather than vanishing or - // sitting as a dead control. A post-hydration effect re-enables it once - // `SpeechRecognition` is confirmed present. + it("hides the mic in SSR markup (dictation is client-and-secure-context only)", () => { + // v1.25.0 — the mic only mounts once the client confirms the Web Speech + // API exists AND the page runs in a secure context. SSR (and any + // unsupported / non-secure environment) renders NO mic at all, so the + // composer never ships an advertised-but-erroring control. The earlier + // "render it disabled" affordance is gone. const html = render( {}} onSubmit={() => {}} />, ); - const micTag = html.match(/]*data-slot="coach-input-mic"[^>]*>/); - expect(micTag).not.toBeNull(); - expect(micTag?.[0]).toMatch(/\sdisabled(=""|\s|>)/); - expect(micTag?.[0]).toContain('data-unsupported="true"'); - // Tooltip explains why it is inert, sourced from i18n. - expect(html).toContain("Voice input is not supported in this browser"); + expect(html).not.toContain('data-slot="coach-input-mic"'); }); it("renders the localised placeholder without the retired shortcut hint", () => { @@ -232,8 +227,9 @@ describe("", () => { expect(html).not.toContain('data-slot="coach-input-hub"'); expect(html).not.toContain('data-slot="coach-input-actions"'); expect(html).not.toContain('data-slot="coach-input-settings"'); - // Mic + send still render in the single-row layout. - expect(html).toContain('data-slot="coach-input-mic"'); + // Send renders in the single-row layout. The mic is client-and-secure- + // context only, so it never appears in SSR markup. + expect(html).not.toContain('data-slot="coach-input-mic"'); expect(html).toContain('data-slot="coach-input-send"'); }); @@ -256,8 +252,9 @@ describe("", () => { expect(html).toContain('data-slot="coach-input-actions"'); // The settings gear is gone from the composer entirely. expect(html).not.toContain('data-slot="coach-input-settings"'); - // Mic + send remain present on the same row. - expect(html).toContain('data-slot="coach-input-mic"'); + // Send remains in the trailing cluster. The mic is client-and-secure- + // context only, so it never appears in SSR markup. + expect(html).not.toContain('data-slot="coach-input-mic"'); expect(html).toContain('data-slot="coach-input-send"'); // The leading `+` precedes the textarea; the textarea precedes the send. const plusIdx = html.indexOf('data-slot="coach-input-actions"'); diff --git a/src/components/insights/coach-panel/__tests__/scope-hint-badge.test.tsx b/src/components/insights/coach-panel/__tests__/scope-hint-badge.test.tsx index 268bce7a0..86003e2b3 100644 --- a/src/components/insights/coach-panel/__tests__/scope-hint-badge.test.tsx +++ b/src/components/insights/coach-panel/__tests__/scope-hint-badge.test.tsx @@ -112,7 +112,7 @@ describe("", () => { expect(html).not.toContain('data-slot="coach-scope-hint-dismiss"'); }); - it("lets the seeded opener fill its column (no max-w-md cap)", () => { + it("sizes the seeded opener to its text and keeps it single-line", () => { const html = render( ", () => { onDismiss={() => {}} />, ); - // The opener grows to the composer column width; the old narrow cap is gone. + const seedTag = html.match( + /]*data-slot="coach-scope-hint-seed"[^>]*>/, + ); + expect(seedTag).not.toBeNull(); + // v1.25.0 — intrinsic width, not stretched to the composer column: the + // opener grows to its text and caps at the full width. The old narrow cap + // and the full-width stretch are both gone. expect(html).not.toContain("max-w-md"); + // Intrinsic width capped at the column: `inline-flex` + `max-w-full`, not + // the old `flex-1` / `w-full` full-width stretch. + expect(seedTag?.[0]).toContain("inline-flex"); + expect(seedTag?.[0]).toContain("max-w-full"); + expect(seedTag?.[0]).not.toMatch(/\bflex-1\b/); + // The question span truncates with an ellipsis rather than wrapping. + expect(html).toContain("truncate"); }); }); diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index 5a9236f6d..1457ed0fc 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useReducer, useState } from "react"; +import { useEffect, useReducer, useRef, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -40,6 +40,7 @@ import { MobileRailTray } from "./mobile-rail-tray"; import { SelfContextAdoptOffer } from "./self-context-adopt-offer"; import { SourcesRail } from "./sources-rail"; import { useResettableValue } from "./use-resettable-value"; +import { useCoachAmbientSuggestionsEnabled } from "@/hooks/use-coach-ambient-suggestions"; import { useCoachConversation, useCoachConversations, @@ -117,6 +118,13 @@ export interface CoachConversationProps { * established scope. Null → the route's default all-source snapshot. */ launchScope?: CoachLaunchScope | null; + /** + * When true, the `prefill` is dispatched as the conversation's first turn + * automatically, exactly once on mount (ref-guarded). Used by the + * assessment hand-off so the answer lands without a manual send. The send + * only fires for a fresh conversation with a non-empty prefill. + */ + autoSend?: boolean; /** * Renders the conversation title. The surface passes the resolved * title string; the drawer wraps it in ``, the page in an @@ -188,6 +196,7 @@ export interface CoachConversationProps { export function CoachConversation({ prefill, launchScope, + autoSend, renderTitle, renderDescription, leadingHeaderActions, @@ -340,6 +349,33 @@ export function CoachConversation({ } } + // Auto-send the prefill as the conversation's first turn, exactly once. + // A card hand-off (e.g. the assessment "ask about this") opens the Coach + // with `autoSend` so the answer lands without a manual send. Ref-guarded so + // it fires a single time per mount; only for a fresh conversation with a + // non-empty prefill and no stream in flight. + const autoSentRef = useRef(false); + // Latest-handler ref so the auto-send effect never lists `handleSubmit` (it + // re-creates each render) and never calls it synchronously in the effect + // body — the dispatch is deferred to a microtask so the composer state it + // touches settles outside the effect. + const handleSubmitRef = useRef(handleSubmit); + useEffect(() => { + handleSubmitRef.current = handleSubmit; + }); + useEffect(() => { + if (!autoSend) return; + if (autoSentRef.current) return; + if (currentConversationId !== null) return; + if (send.isStreaming) return; + const seed = (prefill ?? "").trim(); + if (!seed) return; + autoSentRef.current = true; + queueMicrotask(() => { + void handleSubmitRef.current(seed); + }); + }, [autoSend, prefill, currentConversationId, send.isStreaming]); + // v1.22 — "Try again": regenerate an assistant reply by resubmitting the // user turn that produced it as a FRESH turn. The composer value is left // untouched (unlike `handleSubmit`, which clears it) so a half-typed @@ -481,8 +517,15 @@ export function CoachConversation({ return false; } }); + // v1.25.0 — the per-user opt-out for proactive ambient suggestions gates the + // seeded opener (and skips its fetch when off). The A2 launch scope below is + // NOT ambient — it reflects how the chat was opened — so it stays unaffected. + const ambientSuggestionsEnabled = useCoachAmbientSuggestionsEnabled(); const seededEnabled = - heroActive && a2Metric === null && !seededDismissedToday; + heroActive && + a2Metric === null && + !seededDismissedToday && + ambientSuggestionsEnabled; const { data: seeded } = useQuery({ queryKey: queryKeys.coachSeededQuestion(), queryFn: async () => @@ -539,7 +582,11 @@ export function CoachConversation({ onSeed={seedComposer} /> ); - } else if (seeded?.signal && !seededDismissedToday) { + } else if ( + seeded?.signal && + !seededDismissedToday && + ambientSuggestionsEnabled + ) { // A3 — the notable derived signal. The label + opener are keyed on the // signal's sourceMetric (`readiness` / `recovery`); an unknown sentinel // (future detector additions) skips the opener rather than guessing. diff --git a/src/components/insights/coach-panel/coach-drawer-body.tsx b/src/components/insights/coach-panel/coach-drawer-body.tsx index 3933b2fe4..4263ecaa3 100644 --- a/src/components/insights/coach-panel/coach-drawer-body.tsx +++ b/src/components/insights/coach-panel/coach-drawer-body.tsx @@ -234,7 +234,7 @@ export function CoachDrawerBody({ {/* Composer pinned to the bottom. */}
{composer}
diff --git a/src/components/insights/coach-panel/coach-drawer.tsx b/src/components/insights/coach-panel/coach-drawer.tsx index 490018b21..98e21ef4e 100644 --- a/src/components/insights/coach-panel/coach-drawer.tsx +++ b/src/components/insights/coach-panel/coach-drawer.tsx @@ -54,6 +54,11 @@ export interface CoachDrawerProps { * source(s) + window. Null → the route's default all-source snapshot. */ scope?: CoachLaunchScope | null; + /** + * When true, the conversation auto-sends `prefill` as its first turn + * exactly once on open. Used by the assessment hand-off. + */ + autoSend?: boolean; } export function CoachDrawer({ @@ -61,6 +66,7 @@ export function CoachDrawer({ onOpenChange, prefill, scope, + autoSend, }: CoachDrawerProps) { const { t } = useTranslations(); const router = useRouter(); @@ -139,6 +145,7 @@ export function CoachDrawer({ surface="drawer" prefill={prefill} launchScope={scope} + autoSend={autoSend} autoFocusComposer registerReset={registerReset} // v1.16.1 — the "Conversations" affordance hands off to a diff --git a/src/components/insights/coach-panel/coach-hero.tsx b/src/components/insights/coach-panel/coach-hero.tsx index cd67c0d54..5cc06c4c9 100644 --- a/src/components/insights/coach-panel/coach-hero.tsx +++ b/src/components/insights/coach-panel/coach-hero.tsx @@ -2,6 +2,7 @@ import { Sparkles } from "lucide-react"; +import { MedicalDisclaimer } from "@/components/common/medical-disclaimer"; import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; @@ -121,6 +122,15 @@ export function CoachHero({ composer, scopeHint }: CoachHeroProps) { {scopeHint}
) : null} + + {/* v1.25 — the Coach standing self-description: works from your own + data, not a doctor, not medical advice. Sits once on the calm + new-chat surface so it frames the conversation without nagging + every reply. */} +
); diff --git a/src/components/insights/coach-panel/coach-input.tsx b/src/components/insights/coach-panel/coach-input.tsx index 31f01e4d6..e830fe0c8 100644 --- a/src/components/insights/coach-panel/coach-input.tsx +++ b/src/components/insights/coach-panel/coach-input.tsx @@ -55,10 +55,13 @@ import { useTranslations } from "@/lib/i18n/context"; * transcripts append into the controlled `value`; the button toggles * listening on/off and is fully keyboard- and screen-reader-accessible. * - * v1.18.10 (W4): the mic always renders so the affordance is discoverable. - * On a browser without the Web Speech API (or during SSR) it renders - * DISABLED with an explanatory tooltip rather than vanishing or sitting as - * a dead control that silently does nothing on tap. + * v1.25.0: the mic only renders when dictation is genuinely usable — + * the Web Speech API exists AND the page runs in a secure context (the + * recogniser refuses to start over plain HTTP). On SSR, an unsupported + * browser (Firefox), a non-secure self-host, or after the user denies the + * mic permission, the control HIDES rather than sitting as an + * advertised-but-erroring affordance. A real failure mid-use surfaces a + * single quiet toast and then retires the button for the session. * * v1.18.11 (W11): the composer is the conversation's control hub on the * full-page Coach surface — ChatGPT-style. When `showHub` is set it grows a @@ -193,15 +196,34 @@ function getSpeechRecognitionCtor(): SpeechRecognitionConstructor | null { // mic at SSR) and the client snapshot reflects whether the constructor // exists. The store never changes after hydration, so the subscribe // callback is a no-op. +// +// v1.25.0 — the snapshot also requires a SECURE CONTEXT. `SpeechRecognition` +// throws / fires a `not-allowed` error the instant it starts over plain HTTP, +// so a LAN / Tailscale self-host on `http://` would otherwise advertise a mic +// that can never work. Gating support on `window.isSecureContext` hides it +// there cleanly instead. const noopSubscribe = () => () => {}; function useVoiceSupported(): boolean { return useSyncExternalStore( noopSubscribe, - () => getSpeechRecognitionCtor() !== null, + () => getSpeechRecognitionCtor() !== null && window.isSecureContext, () => false, ); } +// v1.25.0 — map the app locale onto a BCP-47 tag with a region the +// recogniser accepts. The bare language subtag works in most engines, but +// the regioned form ("de-DE", "es-ES", …) yields materially better accuracy +// on Chromium / Safari. Unknown locales fall back to the bare subtag. +const SPEECH_RECOGNITION_LANG: Record = { + en: "en-US", + de: "de-DE", + es: "es-ES", + fr: "fr-FR", + it: "it-IT", + pl: "pl-PL", +}; + export function CoachInput({ value, onChange, @@ -274,6 +296,12 @@ export function CoachInput({ // always read the freshest composer state without re-subscribing. const voiceSupported = useVoiceSupported(); const [listening, setListening] = useState(false); + // v1.25.0 — once the user denies the mic permission (or the engine reports + // it cannot serve), retire the control for the session rather than leaving a + // button that errors on every tap. Combined with `voiceSupported` this is + // the single gate the mic renders behind. + const [voiceBlocked, setVoiceBlocked] = useState(false); + const voiceAvailable = voiceSupported && !voiceBlocked; const recognitionRef = useRef(null); const onChangeRef = useRef(onChange); const valueRef = useRef(value); @@ -299,7 +327,7 @@ export function CoachInput({ const Ctor = getSpeechRecognitionCtor(); if (!Ctor) return; const recognition = new Ctor(); - recognition.lang = locale === "en" ? "en-US" : locale; + recognition.lang = SPEECH_RECOGNITION_LANG[locale] ?? locale; recognition.continuous = true; recognition.interimResults = true; // Anchor every transcript onto the text present when dictation @@ -321,15 +349,17 @@ export function CoachInput({ }; recognition.onerror = (event) => { setListening(false); - // v1.18.10 (W4) — surface a quiet toast on a real failure so a tap - // that "does nothing" (denied mic permission, an insecure-context - // self-host, or no network for the recogniser) gives feedback rather - // than silently resetting. `aborted` / `no-speech` are the benign - // codes that fire on a normal stop or a quiet pause — stay silent. + // `aborted` / `no-speech` are the benign codes that fire on a normal + // stop or a quiet pause — stay silent. const code = event?.error; - if (code && code !== "aborted" && code !== "no-speech") { - toast.error(t("insights.coach.dictateError")); + if (!code || code === "aborted" || code === "no-speech") return; + // v1.25.0 — a permission refusal (or an engine that reports it cannot + // serve) is terminal: surface one quiet toast and HIDE the mic for the + // session so the user is not left tapping a control that always errors. + if (code === "not-allowed" || code === "service-not-allowed") { + setVoiceBlocked(true); } + toast.error(t("insights.coach.dictateError")); }; recognition.onend = () => { setListening(false); @@ -346,18 +376,16 @@ export function CoachInput({ }, [locale, t]); const toggleDictation = useCallback(() => { - if (!voiceSupported) return; + if (!voiceAvailable) return; if (listening) stopDictation(); else startDictation(); - }, [voiceSupported, listening, startDictation, stopDictation]); + }, [voiceAvailable, listening, startDictation, stopDictation]); - // The mic label tracks three states: unsupported (disabled + tooltip - // explaining why), listening (tap to stop), and idle (tap to dictate). - const micLabel = !voiceSupported - ? t("insights.coach.voiceUnsupported") - : listening - ? t("insights.coach.dictateStop") - : t("insights.coach.dictate"); + // The mic only renders when dictation is available, so the label tracks the + // two live states: listening (tap to stop) and idle (tap to dictate). + const micLabel = listening + ? t("insights.coach.dictateStop") + : t("insights.coach.dictate"); const canSubmit = !disabled && value.trim().length > 0; @@ -381,19 +409,20 @@ export function CoachInput({ // v1.18.11 — the dictation mic. Lifted to a const so it can sit in the // single-row composer (drawer) OR in the hub action row (page) without - // duplicating the wiring. - const micButton = ( + // duplicating the wiring. v1.25.0 — `null` when dictation is unavailable + // (no API, non-secure context, or a denied permission) so the surface hides + // the control rather than advertising a button that errors on tap. + const micButton = voiceAvailable ? ( - ); + ) : null; // v1.18.11 — the send / stop control. Same const-lift rationale as the // mic: one definition, two mount points. diff --git a/src/components/insights/coach-panel/guided-questions-machine.ts b/src/components/insights/coach-panel/guided-questions-machine.ts index 33e3e264d..a30e70e41 100644 --- a/src/components/insights/coach-panel/guided-questions-machine.ts +++ b/src/components/insights/coach-panel/guided-questions-machine.ts @@ -36,11 +36,7 @@ /** Lifecycle of one answered question's adopt offer. */ export type GuidedAdoption = - | "pending" - | "adopted" - | "duplicate" - | "declined" - | "failed"; + "pending" | "adopted" | "duplicate" | "declined" | "failed"; export interface GuidedOutcome { /** Index of the question in the session snapshot (progress label). */ diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx index 76f74a65d..686bd83d3 100644 --- a/src/components/insights/coach-panel/message-thread.tsx +++ b/src/components/insights/coach-panel/message-thread.tsx @@ -1046,8 +1046,7 @@ interface ChatBubbleProps { * bubble falls back to that so the card survives reload. */ suggestedAction?: - | import("@/lib/ai/coach/suggest-action").CoachSuggestedAction - | null; + import("@/lib/ai/coach/suggest-action").CoachSuggestedAction | null; providerType?: string | null; inProgress?: boolean; errorCode?: string | null; diff --git a/src/components/insights/coach-panel/scope-hint-badge.tsx b/src/components/insights/coach-panel/scope-hint-badge.tsx index 370231e9a..8fbc980ec 100644 --- a/src/components/insights/coach-panel/scope-hint-badge.tsx +++ b/src/components/insights/coach-panel/scope-hint-badge.tsx @@ -91,28 +91,31 @@ export function ScopeHintBadge({ {/* The tappable seed question — the data-aware opener. A real button so - keyboard + screen-reader users get the same affordance. The opener - fills the column (= the composer's max-width) so it reads as one line - with the field below; the seeded variant adds a dismiss control at the - trailing edge, mirroring the composer's send affordance. */} -
+ keyboard + screen-reader users get the same affordance. v1.25.0 — the + opener sizes to its TEXT (intrinsic width, centred) instead of being + stretched to the full composer width, which left a large empty tail + that read as broken. It is guaranteed single-line: the question + truncates with an ellipsis on a narrow phone rather than wrapping or + forcing a horizontal scroll. The seeded variant keeps the dismiss + control at the trailing edge. */} +
)} - {(updatedAt || noProviderStale) && ( + {(updatedAt || noProviderStale || generationFailed) && (
{updatedAt && (

)} + {/* v1.25 — the held briefing is shown, but the last refresh + attempt failed (provider timeout / error). State it plainly + and offer a retry so the staleness reads as held, not + current. Suppressed when no provider is configured (that + hint owns the footer) — a retry would be futile there. */} + {generationFailed && !noProviderStale && ( +

+ + {t("insights.dailyBriefing.refreshFailedHint")} + + {onRegenerate && ( + + )} +

+ )}
)} {/* v1.21.0 (C4 H2) — hand off to the Coach for the whole @@ -542,8 +577,20 @@ export function DailyBriefing({ data-slot="daily-briefing-empty" variant="plain" icon={} - title={t("insights.dailyBriefing.emptyTitle")} - description={t("insights.dailyBriefing.emptyDescription")} + // v1.25 — when the last attempt FAILED and there is no last good + // text to fall back on, say so honestly ("couldn't generate") + // rather than the generic "no briefing yet". The CTA below is the + // same explicit regenerate — it retries the failed generation. + title={ + generationFailed + ? t("insights.dailyBriefing.failedTitle") + : t("insights.dailyBriefing.emptyTitle") + } + description={ + generationFailed + ? t("insights.dailyBriefing.failedDescription") + : t("insights.dailyBriefing.emptyDescription") + } action={ onRegenerate ? ( // v1.4.28 BK-M2 — switch the empty-state CTA from @@ -564,7 +611,9 @@ export function DailyBriefing({ {regenerating ? t("insights.heroRegenerating") - : t("insights.dailyBriefing.emptyAction")} + : generationFailed + ? t("insights.dailyBriefing.retryAction") + : t("insights.dailyBriefing.emptyAction")} ) : null diff --git a/src/components/insights/derived/trajectory-fan.tsx b/src/components/insights/derived/trajectory-fan.tsx deleted file mode 100644 index d3f8b11ad..000000000 --- a/src/components/insights/derived/trajectory-fan.tsx +++ /dev/null @@ -1,79 +0,0 @@ -"use client"; - -import { - ResponsiveContainer, - ComposedChart, - Area, - Line, - XAxis, - YAxis, - ReferenceLine, -} from "recharts"; - -/** - * v1.12.1 — the Recharts fan band of the trajectory forecast card, - * split into its own module so it can be `next/dynamic`-deferred. - * - * `TrajectoryForecastCard` is mounted by the shared `HealthKitMetricPage` - * on every trajectory-eligible metric page; before this split it pulled - * Recharts into the page's initial chunk eagerly (the main metric chart - * is already deferred via `HealthChartDynamic`). Isolating the only - * Recharts-touching subtree here lets the card chrome render without the - * chart library and lazy-load this chunk only when there's a projection - * to draw — keeping the chart visually identical (same ComposedChart / - * Area / Line / ReferenceLine geometry, same `isAnimationActive={false}`). - */ - -export interface TrajectoryFanPoint { - date: string; - projected: number; - /** Stacked transparent base (= bandLow). */ - base: number; - /** Shaded band height (= bandHigh − bandLow). */ - range: number; -} - -export function TrajectoryFan({ - points, - color, -}: { - points: TrajectoryFanPoint[]; - color: string; -}) { - return ( - - - - - - - - - - - ); -} diff --git a/src/components/insights/derived/trajectory-forecast-card.tsx b/src/components/insights/derived/trajectory-forecast-card.tsx deleted file mode 100644 index d9cc97903..000000000 --- a/src/components/insights/derived/trajectory-forecast-card.tsx +++ /dev/null @@ -1,331 +0,0 @@ -"use client"; - -import { useMemo } from "react"; -import dynamic from "next/dynamic"; -import { TrendingUp, TrendingDown, Minus } from "lucide-react"; - -import { useTranslations } from "@/lib/i18n/context"; -import { cn } from "@/lib/utils"; -import { LearningGate } from "@/components/ui/learning-gate"; -import { CoverageMeter } from "@/components/insights/derived/coverage-meter"; -import { ProvenanceExplainer } from "@/components/insights/derived/provenance-explainer"; -import { METRIC_PROVENANCE } from "@/components/insights/derived/standards"; -import { useDerivedMetric } from "@/components/insights/derived/use-derived-metric"; - -// v1.12.1 — defer the Recharts fan band so the card chrome (and the whole -// HealthKit metric page that always mounts this card on a trajectory- -// eligible type) no longer pulls Recharts into the initial chunk. The -// loading shell is a plain box that fills the same fixed-height band the -// chart paints, so there is no layout shift when the chunk arrives. -const TrajectoryFanDynamic = dynamic( - () => - import("@/components/insights/derived/trajectory-fan").then((mod) => ({ - default: mod.TrajectoryFan, - })), - { - ssr: false, - loading: () =>
, - }, -); -// Type-only — the compute payload never drags the server graph into the -// client bundle (the v1.9.0 lesson, mirrored across the derived surfaces). -import type { TrajectoryValue } from "@/lib/insights/derived/trajectory"; -import type { DerivedProvenance } from "@/lib/insights/derived/types"; - -/** - * v1.11.0 (Epic B, Pillar 3) — the short-horizon projection card on the - * metric detail page. - * - * Surfaces the deterministic `TRAJECTORY` derived metric as a small band - * chart: the recent observed line, then the projected line (dashed) with a - * widening prediction-interval fan. The fan VISIBLY widens with the horizon - * — that is the uncertainty communication, no words needed. The number is - * computed server-side; this card only draws it. It is explicitly labelled - * a PROJECTION ("if this pattern continues"), never a prediction or a dated - * event, and the cited method (OLS + prediction interval) rides the ⓘ - * provenance affordance. - * - * Below the engine's R² / history / staleness floor the engine returns - * `insufficient`, and the card renders the same calm "not enough of a trend - * to project yet" + coverage state every derived surface uses — never a weak - * line, never a blank. - * - * The card mounts as a sibling beneath the main metric chart (mirroring - * `MetricStatusCard`), so the main chart's visual identity is untouched. - */ - -const TRAJECTORY_METRIC = "TRAJECTORY"; - -/** - * Mirror of `TRAJECTORY_MIN_HISTORY_DAYS` (`src/lib/insights/derived/trajectory.ts`). - * Re-declared as a client-side literal rather than value-imported because the - * engine module pulls the rollup/baseline server graph in at its top level — - * importing the constant would drag that graph into the client bundle (the same - * reason `TrajectoryValue` is a type-only import). Keep this in lockstep with - * the engine; the unit test pins both at 14. - */ -const TRAJECTORY_MIN_HISTORY_DAYS = 14; - -/** A single value-scale used to render stored values in the display unit. */ -function scaled(value: number, scale: number): number { - return value * scale; -} - -interface TrajectoryForecastCardProps { - /** The MeasurementType to project (the page's canonical type). */ - type: string; - /** Display unit suffix (e.g. "kg"). */ - unit?: string; - /** Display scale folded into every rendered value (defaults to 1). */ - valueScale?: number; - /** Canonical chart colour for the metric's line. */ - color?: string; - /** Gate the underlying derived read (e.g. on the auth/empty flag). */ - enabled?: boolean; - /** - * v1.12.0 — density by render context. Sub-pages pass `compact` so the - * card sheds the `min-h-48` floor + drops to a shorter chart and a tighter - * no-trend state (a too-tall "1 of 1" empty band was the maintainer flag). - * The Insights OVERVIEW grid omits it so the tile KEEPS its current size - * for grid symmetry — the default is the unchanged, roomier geometry. - */ - compact?: boolean; - className?: string; -} - -/** The provenance ⓘ explainer for the projection, wired from the map. */ -function TrajectoryProvenance({ - provenance, -}: { - provenance: DerivedProvenance; -}) { - const { t } = useTranslations(); - const meta = METRIC_PROVENANCE.TRAJECTORY; - const method = ( - <> - {meta.caveatKey ? ( - - {t(meta.caveatKey)} - - ) : null} - {t(meta.methodKey)} - - ); - return ( - - ); -} - -/** Card shell — uppercase label + provenance affordance + body children. */ -function CardShell({ - provenance, - compact = false, - children, -}: { - provenance?: DerivedProvenance; - compact?: boolean; - children: React.ReactNode; -}) { - const { t } = useTranslations(); - return ( -
-
- - {t("insights.derived.trajectory.cardTitle")} - - {provenance ? : null} -
- {children} -
- ); -} - -/** CLS-safe skeleton matching the resolved card geometry. */ -function CardSkeleton({ compact = false }: { compact?: boolean }) { - return ( - ); } diff --git a/src/components/labs/lab-history-list.tsx b/src/components/labs/lab-history-list.tsx index 98b072074..17cfb3e50 100644 --- a/src/components/labs/lab-history-list.tsx +++ b/src/components/labs/lab-history-list.tsx @@ -1,17 +1,26 @@ "use client"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Loader2, Pencil } from "lucide-react"; import { toast } from "sonner"; -import { DeleteButton } from "@/components/data-list"; +import { DeleteButton, SortableHead } from "@/components/data-list"; import { Button } from "@/components/ui/button"; import { DateTimeField } from "@/components/ui/date-time-field"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; import { Textarea } from "@/components/ui/textarea"; +import { useTableSort } from "@/hooks/use-table-sort"; import { ApiError, apiDelete, @@ -30,6 +39,19 @@ import type { LabResultDetailDto, LabResultDto } from "./types"; const NOTE_MAX_LENGTH = 2000; +// The sample-date column opens descending (newest first); every other column +// opens ascending. +const LAB_DESC_COLUMNS = new Set(["takenAt"]); + +// Stable ordering for the reference-range column so a sort produces a +// predictable below → in-range → above → unknown progression. +const RANGE_RANK: Record = { + below: 0, + "in-range": 1, + above: 2, + unknown: 3, +}; + function toDateTimeLocal(iso: string): string { const d = new Date(iso); const offset = d.getTimezoneOffset(); @@ -55,6 +77,13 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) { const { t } = useTranslations(); const queryClient = useQueryClient(); + // Client-side column sort over the in-memory reading feed. + const { sortBy, sortDir, toggleSort } = useTableSort({ + defaultColumn: "takenAt", + defaultDir: "desc", + descColumns: LAB_DESC_COLUMNS, + }); + const [editingId, setEditingId] = useState(null); const [editValue, setEditValue] = useState(""); // v1.18.9 — true when the row being edited is a qualitative reading; the @@ -200,54 +229,124 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) { }); } - const ordered = [...readings].sort( - (a, b) => new Date(b.takenAt).getTime() - new Date(a.takenAt).getTime(), - ); + const ordered = useMemo(() => { + const dir = sortDir === "asc" ? 1 : -1; + return [...readings].sort((a, b) => { + let cmp = 0; + switch (sortBy) { + case "value": { + // Numeric rows compare by value; qualitative rows (value null) sort + // after the numeric ones and tie-break on their text. + if (a.value !== null && b.value !== null) { + cmp = a.value - b.value; + } else if (a.value === null && b.value === null) { + cmp = (a.valueText ?? "").localeCompare(b.valueText ?? ""); + } else { + cmp = a.value === null ? 1 : -1; + } + break; + } + case "range": + cmp = + (RANGE_RANK[a.rangeStatus] ?? 99) - + (RANGE_RANK[b.rangeStatus] ?? 99); + break; + case "note": + cmp = Number(a.hasNote) - Number(b.hasNote); + break; + case "takenAt": + default: + cmp = new Date(a.takenAt).getTime() - new Date(b.takenAt).getTime(); + break; + } + // Stable secondary ordering by sample date keeps ties deterministic. + if (cmp === 0) { + cmp = new Date(a.takenAt).getTime() - new Date(b.takenAt).getTime(); + } + return cmp * dir; + }); + }, [readings, sortBy, sortDir]); return ( <> -
    - {ordered.map((r) => ( -
  • -
    -
    - - {formatLabReading(r)} - - -
    -

    + + + + + + + + + {t("labs.editReading")} + + + + + {ordered.map((r) => ( + + + {formatLabReading(r)} + + {formatDateShort(r.takenAt, true)} - {r.hasNote ? ` · ${t("labs.hasNote")}` : ""} -

    - -
    - - deleteMutation.mutate(r.id)} - title={t("labs.deleteConfirmTitle")} - description={t("labs.deleteConfirmDescription")} - confirmLabel={t("labs.deleteReading")} - className="size-11 sm:size-9" - iconClassName="h-4 w-4" - /> -
    - - ))} - +
    + + + + + {r.hasNote ? t("labs.hasNote") : ""} + + +
    + + deleteMutation.mutate(r.id)} + title={t("labs.deleteConfirmTitle")} + description={t("labs.deleteConfirmDescription")} + confirmLabel={t("labs.deleteReading")} + className="size-11 sm:size-9" + iconClassName="h-4 w-4" + /> +
    +
    +
    + ))} +
    +
    void } = {}) { // v1.22 — a short, factual line under each marker heading describing what the // biomarker measures, sourced from the catalog via i18n. Free-text markers // (no catalog match) carry no subtitle rather than a fabricated one. - // v1.22.1 — build the localized name→slug map once per locale instead of - // re-scanning all catalog seeds (calling `t()` each) per group every render. - const slugByName = useMemo(() => { - const map = new Map(); - for (const seed of BIOMARKER_CATALOG) { - const norm = t(`labs.catalog.${seed.slug}`).trim().toLowerCase(); - if (norm) map.set(norm, seed.slug); - } - return map; - }, [t]); - const describeMarker = (analyte: string): string | null => { - const norm = analyte.trim().toLowerCase(); - if (!norm) return null; - const slug = slugByName.get(norm); - return slug ? t(`labs.catalog.desc.${slug}`) : null; - }; - + // v1.24 — the per-marker description moved to the biomarker detail page + // (beneath the heading); the overview rows no longer carry it. const listKey = queryKeys.labResultsList({ biomarkerId: undefined, analyte: undefined, @@ -165,7 +149,7 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { if (isError) { return ( -

    +

    {t("labs.loadError")}

    ); @@ -204,7 +188,6 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { {groups.map((group) => { - const description = describeMarker(group.analyte); const inner = (
    @@ -216,11 +199,6 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { compact />
    - {description ? ( -

    - {description} -

    - ) : null}
    {formatLabReading(group.latest)} @@ -278,7 +256,6 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
  • ) : null} {groups.map((group) => { - const description = describeMarker(group.analyte); return (
  • @@ -298,11 +275,6 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { linkLabel={group.analyte} /> - {description ? ( -

    - {description} -

    - ) : null}
    diff --git a/src/components/layout/nav-model.ts b/src/components/layout/nav-model.ts index 0a2444840..49e04b45b 100644 --- a/src/components/layout/nav-model.ts +++ b/src/components/layout/nav-model.ts @@ -1,7 +1,9 @@ import { Activity, Bell, + Brain, Droplets, + FileScan, FlaskConical, Home, Lightbulb, @@ -81,6 +83,19 @@ export const NAV_DESTINATIONS: ReadonlyArray = [ tourId: "nav-mood", requiresModule: "mood", }, + // v1.25.0 — opt-in mental-health screeners (PHQ-9 / GAD-7), beside mood. + // Born-gated: `requiresModule: "mentalHealth"` reads the opt-in (default-off) + // key from the resolved module map, so the entry is absent until the account + // turns the module on from the Modules hub. The destination is the dedicated + // `/insights/mental-wellbeing` check-in surface (its own nav home — the + // active-detection helper defers `/insights` to it on that path). + { + href: "/insights/mental-wellbeing", + tKey: "nav.mentalWellbeing", + icon: Brain, + tourId: "nav-mental-wellbeing", + requiresModule: "mentalHealth", + }, { href: "/cycle", tKey: "nav.cycle", @@ -137,6 +152,17 @@ export const NAV_DESTINATIONS: ReadonlyArray = [ // surfaces as an Insights tab-strip pill (`/insights/workouts` gated on // a workout row, `/insights/recovery` always present), so neither is a // top-level `NAV_DESTINATIONS` entry any more. + // v1.25.0 (W-DOCS-IN) — inbound clinical documents sit in the clinical + // spine after Illness. Born-gated: `requiresModule: "inboundDocuments"` + // reads the opt-in key from the resolved module map, so the entry is absent + // until the account turns the module on from the Modules hub. + { + href: "/documents", + tKey: "nav.documents", + icon: FileScan, + tourId: "nav-documents", + requiresModule: "inboundDocuments", + }, { href: "/insights", tKey: "nav.insights", diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx index 280702666..e43368c38 100644 --- a/src/components/measurement-reminders/vorsorge-section.tsx +++ b/src/components/measurement-reminders/vorsorge-section.tsx @@ -35,6 +35,7 @@ import { CalendarClock, CheckCircle2, Plus, + RefreshCw, Wrench, } from "lucide-react"; @@ -219,7 +220,12 @@ export function VorsorgeSection({ variant?: "settings" | "page"; }) { const { t } = useTranslations(); - const { data: reminders, isLoading } = useMeasurementReminders(enabled); + const { + data: reminders, + isLoading, + isError, + refetch, + } = useMeasurementReminders(enabled); const { create, update, remove, satisfy } = useMeasurementReminderMutations(); // v1.18.6 (MOD-03) — page view (cards/list) + manual order persist // client-side; the settings page writes them, the page reads them. @@ -662,7 +668,27 @@ export function VorsorgeSection({
    )} - {!isLoading && (reminders?.length ?? 0) === 0 && ( + {!isLoading && isError && ( +
    + {t("measurementReminders.loadError")} + +
    + )} + + {!isLoading && !isError && (reminders?.length ?? 0) === 0 && ( } title={t("measurementReminders.empty.title")} diff --git a/src/components/measurements/measurement-form.tsx b/src/components/measurements/measurement-form.tsx index ab6cfba5d..85733c329 100644 --- a/src/components/measurements/measurement-form.tsx +++ b/src/components/measurements/measurement-form.tsx @@ -95,6 +95,32 @@ const MEASUREMENT_TYPES = [ unit: "°C", placeholder: "36.6", }, + // v1.25 — physical / clinical signals. Manual web capture so a web-only + // self-hoster can log them (the iOS client + measurements API already do). + { + value: "WAIST_CIRCUMFERENCE", + labelKey: "measurements.typeWaistCircumference", + unit: "cm", + placeholder: "84", + }, + { + value: "WAIST_TO_HEIGHT", + labelKey: "measurements.typeWaistToHeight", + unitKey: "measurements.unitRatio", + placeholder: "0.48", + }, + { + value: "GRIP_STRENGTH", + labelKey: "measurements.typeGripStrength", + unit: "kg", + placeholder: "38", + }, + { + value: "PAIN_NRS", + labelKey: "measurements.typePainNrs", + unitKey: "measurements.unitScore", + placeholder: "0", + }, ] as const; // v1.4.34 IW-G — single source of truth for the `/measurements?add=` diff --git a/src/components/measurements/measurement-list-meta.ts b/src/components/measurements/measurement-list-meta.ts index f27dd5ef2..aabef8a6d 100644 --- a/src/components/measurements/measurement-list-meta.ts +++ b/src/components/measurements/measurement-list-meta.ts @@ -122,6 +122,13 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record = { BODY_TEMPERATURE_DEVIATION: "measurements.typeBodyTemperatureDeviation", // ── v1.19.0 — Oura resilience ── RESILIENCE: "measurements.typeResilience", + // ── v1.25 — clinical-signals wave ── + PHQ9_SCORE: "measurements.typePhq9Score", + GAD7_SCORE: "measurements.typeGad7Score", + GRIP_STRENGTH: "measurements.typeGripStrength", + PAIN_NRS: "measurements.typePainNrs", + WAIST_CIRCUMFERENCE: "measurements.typeWaistCircumference", + WAIST_TO_HEIGHT: "measurements.typeWaistToHeight", }; export const MEASUREMENT_TYPE_ICONS: Record = { @@ -245,6 +252,15 @@ export const MEASUREMENT_TYPE_ICONS: Record = { // Gauge reads as the composite "index / level" dial, matching the other // recovery / score composites (RECOVERY_SCORE, DAY_STRAIN). RESILIENCE: Gauge, + // ── v1.25 — clinical-signals wave ── + // Gauge reads as the screener-score dial; Dumbbell carries grip strength; + // Activity the pain trace; Scale the anthropometric waist pair. + PHQ9_SCORE: Gauge, + GAD7_SCORE: Gauge, + GRIP_STRENGTH: Dumbbell, + PAIN_NRS: Activity, + WAIST_CIRCUMFERENCE: Scale, + WAIST_TO_HEIGHT: Scale, }; export const MEASUREMENT_TYPE_COLORS: Record = { @@ -359,4 +375,13 @@ export const MEASUREMENT_TYPE_COLORS: Record = { // chart-1 (Dracula purple) marks the derived recovery / score composites as // their own visual group, matching RECOVERY_SCORE / DAY_STRAIN. RESILIENCE: "bg-chart-1/20 text-chart-1", + // ── v1.25 — clinical-signals wave ── + // chart-1 (composite/score family) for the screener totals + grip strength; + // chart-3 (cardio/alert family) for pain; chart-4 (body family) for waist. + PHQ9_SCORE: "bg-chart-1/20 text-chart-1", + GAD7_SCORE: "bg-chart-1/20 text-chart-1", + GRIP_STRENGTH: "bg-chart-1/20 text-chart-1", + PAIN_NRS: "bg-chart-3/20 text-chart-3", + WAIST_CIRCUMFERENCE: "bg-chart-4/20 text-chart-4", + WAIST_TO_HEIGHT: "bg-chart-4/20 text-chart-4", }; diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index 58a194307..8d7f918eb 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -52,6 +52,7 @@ import { MoreHorizontal, } from "lucide-react"; import { Fragment, useCallback, useId, useState } from "react"; +import { useTableSort } from "@/hooks/use-table-sort"; import { createPortal } from "react-dom"; import { toast } from "sonner"; import { formatDateOrRelative, formatDateTime } from "@/lib/format"; @@ -105,6 +106,10 @@ import { */ const CUMULATIVE_TYPES = new Set(CUMULATIVE_DAY_SUM_TYPES); +// Columns that open descending when first selected. The date column reads +// newest-first by default; every other column opens ascending. +const MEASUREMENT_DESC_COLUMNS: ReadonlySet = new Set(["measuredAt"]); + interface Measurement { id: string; type: string; @@ -313,8 +318,16 @@ export function MeasurementList({ const [valueMinInput, setValueMinInputRaw] = useState(""); const [valueMaxInput, setValueMaxInputRaw] = useState(""); const [page, setPage] = useState(1); - const [sortBy, setSortBy] = useState("measuredAt"); - const [sortDir, setSortDir] = useState<"asc" | "desc">("desc"); + // Shared column-sort state (measuredAt opens descending). + const { + sortBy, + sortDir, + toggleSort: applySort, + } = useTableSort({ + defaultColumn: "measuredAt", + defaultDir: "desc", + descColumns: MEASUREMENT_DESC_COLUMNS, + }); // v1.15.13 — page-scoped multi-select. Holds the ids selected on the // CURRENT page; cleared on any page / filter / sort change (per the @@ -399,13 +412,11 @@ export function MeasurementList({ clearSelection(); }; + // Compose the page-reset + selection-clear side effects around the + // shared sort toggle (the rows the selection referred to are about to + // re-order and the list snaps back to page 1). function toggleSort(column: string) { - if (sortBy === column) { - setSortDir((d) => (d === "asc" ? "desc" : "asc")); - } else { - setSortBy(column); - setSortDir(column === "measuredAt" ? "desc" : "asc"); - } + applySort(column); setPage(1); clearSelection(); } diff --git a/src/components/medications/__tests__/supply-runway.test.ts b/src/components/medications/__tests__/supply-runway.test.ts index d1c11ad56..77ea502d7 100644 --- a/src/components/medications/__tests__/supply-runway.test.ts +++ b/src/components/medications/__tests__/supply-runway.test.ts @@ -68,6 +68,43 @@ describe("estimateDailyDoseCount", () => { ).toBeCloseTo(1 / 30); }); + it("treats a once-weekly RRULE as one dose per 7 days", () => { + // FREQ=WEEKLY;BYDAY=MO is the modern once-weekly injection encoding; + // it must NOT fall through to the daysPerWeek=7 legacy branch. + expect( + estimateDailyDoseCount([ + schedule({ timesOfDay: ["08:00"], rrule: "FREQ=WEEKLY;BYDAY=MO" }), + ]), + ).toBeCloseTo(1 / 7); + }); + + it("halves a bi-weekly RRULE via INTERVAL=2 (one dose per 14 days)", () => { + expect( + estimateDailyDoseCount([ + schedule({ + timesOfDay: ["08:00"], + rrule: "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO", + }), + ]), + ).toBeCloseTo(1 / 14); + }); + + it("counts each BYDAY pick on a weekly RRULE (Mon+Thu → 2 per 7 days)", () => { + expect( + estimateDailyDoseCount([ + schedule({ timesOfDay: ["08:00"], rrule: "FREQ=WEEKLY;BYDAY=MO,TH" }), + ]), + ).toBeCloseTo(2 / 7); + }); + + it("defaults a weekly RRULE with no BYDAY to one pick per week", () => { + expect( + estimateDailyDoseCount([ + schedule({ timesOfDay: ["08:00"], rrule: "FREQ=WEEKLY" }), + ]), + ).toBeCloseTo(1 / 7); + }); + it("sums across multiple schedules", () => { expect( estimateDailyDoseCount([ diff --git a/src/components/medications/__tests__/use-medication-intake.test.ts b/src/components/medications/__tests__/use-medication-intake.test.ts index a7bf9078e..c54b4a8ba 100644 --- a/src/components/medications/__tests__/use-medication-intake.test.ts +++ b/src/components/medications/__tests__/use-medication-intake.test.ts @@ -133,8 +133,7 @@ describe("runRecordIntake — shared C1 failure toast + C2 Undo", () => { const [message, opts] = vi.mocked(toast.success).mock.calls[0]; expect(message).toBe("medications.intakeToastTaken:Mounjaro"); const action = opts?.action as - | { label: string; onClick: (e: never) => void } - | undefined; + { label: string; onClick: (e: never) => void } | undefined; expect(action?.label).toBe("medications.intakeUndo"); // Firing the action calls undoIntake with the just-created event id. diff --git a/src/components/medications/detail/supply-runway.ts b/src/components/medications/detail/supply-runway.ts index b4972e946..bbc36a58f 100644 --- a/src/components/medications/detail/supply-runway.ts +++ b/src/components/medications/detail/supply-runway.ts @@ -46,6 +46,25 @@ export function estimateDailyDoseCount(schedules: RunwaySchedule[]): number { perDay += times / 365; continue; } + // FREQ=WEEKLY;BYDAY=…;INTERVAL=… is the modern weekly encoding (the + // create path stores the cadence on the rrule and leaves daysOfWeek + // empty). Honour the BYDAY pick count and the week INTERVAL: a + // once-weekly injection is one dose per 7 days, a bi-weekly one per + // 14. Without this branch a weekly rrule fell through to the legacy + // daysOfWeek fallback below with daysPerWeek=7, over-estimating the + // rate ~7× (≈14× bi-weekly) and firing low-stock alerts far too early. + if (/FREQ=WEEKLY/.test(rrule)) { + const byday = /BYDAY=([^;]+)/.exec(rrule); + const bydayCount = + byday && byday[1].length > 0 ? byday[1].split(",").length : 1; + const intervalMatch = /INTERVAL=(\d+)/.exec(rrule); + const interval = + intervalMatch && Number(intervalMatch[1]) >= 1 + ? Number(intervalMatch[1]) + : 1; + perDay += (times * bydayCount) / (7 * interval); + continue; + } const { daysOfWeek, intervalWeeks } = parseScheduleRecurrence(s.daysOfWeek); const daysPerWeek = daysOfWeek.length > 0 ? daysOfWeek.length : 7; const weeks = intervalWeeks >= 1 ? intervalWeeks : 1; diff --git a/src/components/medications/medication-table.tsx b/src/components/medications/medication-table.tsx index 7085498bc..dd97b5c5e 100644 --- a/src/components/medications/medication-table.tsx +++ b/src/components/medications/medication-table.tsx @@ -98,10 +98,7 @@ export interface TableMedication { } export type MedicationTableSortColumn = - | "name" - | "nextDue" - | "compliance" - | "stock"; + "name" | "nextDue" | "compliance" | "stock"; export interface MedicationTableSort { column: MedicationTableSortColumn; diff --git a/src/components/medications/scheduling/schedule-times-editor.tsx b/src/components/medications/scheduling/schedule-times-editor.tsx index 58f24262e..0ea7cfdf0 100644 --- a/src/components/medications/scheduling/schedule-times-editor.tsx +++ b/src/components/medications/scheduling/schedule-times-editor.tsx @@ -144,9 +144,7 @@ export function ScheduleTimesEditor({ ...(s.scheduleType ? { scheduleType: s.scheduleType as - | "SCHEDULED" - | "PRN" - | "CYCLIC", + "SCHEDULED" | "PRN" | "CYCLIC", } : {}), ...(typeof s.cyclicOnWeeks === "number" diff --git a/src/components/medications/sections/settings-section.tsx b/src/components/medications/sections/settings-section.tsx index 2253b1728..64856df27 100644 --- a/src/components/medications/sections/settings-section.tsx +++ b/src/components/medications/sections/settings-section.tsx @@ -107,9 +107,7 @@ export function GraceRow({ ...(s.scheduleType ? { scheduleType: s.scheduleType as - | "SCHEDULED" - | "PRN" - | "CYCLIC", + "SCHEDULED" | "PRN" | "CYCLIC", } : {}), ...(typeof s.cyclicOnWeeks === "number" diff --git a/src/components/mental-health/mental-wellbeing.tsx b/src/components/mental-health/mental-wellbeing.tsx new file mode 100644 index 000000000..1cd85d9c1 --- /dev/null +++ b/src/components/mental-health/mental-wellbeing.tsx @@ -0,0 +1,385 @@ +"use client"; + +/** + * v1.25 — opt-in mental-health screener surface (PHQ-9 / GAD-7). + * + * Beside mood tracking, never replacing it; framed as a screening self-check, + * not a diagnosis. The per-item answers stay on the device until submit, the + * server encrypts them, and only the total trend is persisted as a measurement. + * + * SAFETY: this surface is deliberately kept OFF the AI Coach — the shared + * Coach-launch assessment card is intentionally NOT used here, so a depression / + * anxiety screen never invites an AI conversation about item content. On a + * positive PHQ-9 item-9 the server returns a calm, locale-aware crisis-resource + * set which is rendered immediately below the result. + */ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { useTranslations, useFormatters } from "@/lib/i18n/context"; +import { apiGet, apiPost } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + INSTRUMENTS, + PHQ_GAD_ATTRIBUTION, + type InstrumentId, +} from "@/lib/mental-health/instruments"; + +type Phase = "choose" | "form" | "result"; + +interface AssessmentRow { + id: string; + instrument: InstrumentId; + totalScore: number; + severityBand: string; + item9Flagged: boolean; + takenAt: string; +} + +interface CrisisSet { + emergencyNumber: string; + resources: { id: string; contacts: string[] }[]; +} + +interface CreateResponse { + assessment: AssessmentRow; + actionThreshold: number; + crisis: CrisisSet | null; +} + +const SCALE = [0, 1, 2, 3] as const; +const FUNCTIONAL = [0, 1, 2, 3] as const; + +export function MentalWellbeing() { + const { t, locale } = useTranslations(); + const { date: formatDate } = useFormatters(); + const queryClient = useQueryClient(); + + const [phase, setPhase] = useState("choose"); + const [instrument, setInstrument] = useState("PHQ9"); + const [items, setItems] = useState([]); + const [functional, setFunctional] = useState(null); + const [result, setResult] = useState(null); + + // a11y: on submit the form (incl. the submit button) unmounts and `phase` + // flips to "result". Move focus to the result heading so keyboard / screen- + // reader users are not dropped to `` and land on the new content. + const resultHeadingRef = useRef(null); + useEffect(() => { + if (phase === "result") { + resultHeadingRef.current?.focus(); + } + }, [phase]); + + const lower = (id: InstrumentId) => (id === "PHQ9" ? "phq9" : "gad7"); + + const { data: history } = useQuery({ + queryKey: queryKeys.mentalHealthAssessments(), + queryFn: () => + apiGet<{ assessments: AssessmentRow[] }>( + "/api/mental-health/assessments", + ), + }); + + const mutation = useMutation({ + mutationFn: (body: unknown) => + apiPost("/api/mental-health/assessments", body), + onSuccess: (data) => { + setResult(data); + setPhase("result"); + void queryClient.invalidateQueries({ + queryKey: queryKeys.mentalHealthAssessments(), + }); + }, + }); + + function begin(id: InstrumentId) { + setInstrument(id); + setItems(Array(INSTRUMENTS[id].itemCount).fill(-1)); + setFunctional(null); + setResult(null); + setPhase("form"); + } + + const complete = items.length > 0 && items.every((v) => v >= 0); + + function submit() { + if (!complete) return; + mutation.mutate({ + instrument, + items, + ...(functional !== null ? { functionalDifficulty: functional } : {}), + locale, + }); + } + + const itemCount = INSTRUMENTS[instrument].itemCount; + const itemIndexes = useMemo( + () => Array.from({ length: itemCount }, (_, i) => i), + [itemCount], + ); + + return ( +
    +
    +

    + {t("mentalHealth.pageTitle")} +

    +

    + {t("mentalHealth.pageDescription")} +

    +

    + {t("mentalHealth.disclaimer")} +

    +

    + {t("mentalHealth.optInNote")} +

    +
    + + {phase === "choose" && ( +
    +

    + {t("mentalHealth.choosePrompt")} +

    + {(["PHQ9", "GAD7"] as InstrumentId[]).map((id) => ( + + +
    + + {t(`mentalHealth.instrument.${lower(id)}`)} + + + {t(`mentalHealth.instrumentSub.${lower(id)}`)} + +
    + +
    +
    + ))} +
    + )} + + {phase === "form" && ( +
    +
    + + + {t(`mentalHealth.instrument.${lower(instrument)}`)} + +
    +

    {t("mentalHealth.stem")}

    + +
      + {itemIndexes.map((i) => ( +
    1. + + {i + 1}.{" "} + {t(`mentalHealth.items.${lower(instrument)}.${i + 1}`)} + +
      + {SCALE.map((v) => ( + + ))} +
      +
    2. + ))} +
    + +
    + {t("mentalHealth.functionalTitle")} +
    + {FUNCTIONAL.map((v) => ( + + ))} +
    +
    + + {mutation.isError && ( +

    + {t("mentalHealth.error")} +

    + )} + +
    + )} + + {phase === "result" && result && ( +
    +

    + {t("mentalHealth.saved")} +

    + + + + {t("mentalHealth.result.title")} + + + +
    + + {t("mentalHealth.result.totalLabel")} + + + {result.assessment.totalScore} + +
    +
    + + {t("mentalHealth.result.bandLabel")} + + + {t( + `mentalHealth.band.${result.assessment.instrument}.${result.assessment.severityBand}`, + )} + +
    + {result.assessment.totalScore >= result.actionThreshold && ( +

    + {t("mentalHealth.considerProfessional")} +

    + )} +
    +
    + + {result.crisis && ( + + + + {t("mentalHealth.crisis.title")} + + + +

    {t("mentalHealth.crisis.intro")}

    +

    + {t("mentalHealth.crisis.ifDanger", { + emergency: result.crisis.emergencyNumber, + })} +

    +
    + + {t("mentalHealth.crisis.resourcesTitle")} + +
      + {result.crisis.resources.map((r) => ( +
    • + + {t(`mentalHealth.crisisResource.${r.id}.name`)} + + + {r.contacts.join(" · ")} + +
    • + ))} +
    +
    +
    +
    + )} + +

    + + {t("mentalHealth.attributionLabel")}: + {" "} + {PHQ_GAD_ATTRIBUTION} +

    + + +
    + )} + +
    +

    + {t("mentalHealth.history.title")} +

    + {!history || history.assessments.length === 0 ? ( +

    + {t("mentalHealth.history.empty")} +

    + ) : ( +
      + {history.assessments.map((row) => ( +
    • + + {t(`mentalHealth.instrument.${lower(row.instrument)}`)} + + + {t(`mentalHealth.band.${row.instrument}.${row.severityBand}`)}{" "} + · {formatDate(row.takenAt)} + +
    • + ))} +
    + )} +
    +
    + ); +} diff --git a/src/components/mood/manage/tag-manager-card.tsx b/src/components/mood/manage/tag-manager-card.tsx index 1e7a25fa6..34f37066f 100644 --- a/src/components/mood/manage/tag-manager-card.tsx +++ b/src/components/mood/manage/tag-manager-card.tsx @@ -502,7 +502,7 @@ function SortableTagRow({ data-tag={tag.key} data-hidden={isHidden ? "true" : undefined} data-dragging={isDragging ? "true" : undefined} - className={`border-border bg-background/30 flex min-h-12 items-center gap-2 rounded-md border px-3 py-2 ${ + className={`border-border bg-background/30 flex min-h-12 flex-wrap items-center gap-2 rounded-md border px-3 py-2 ${ isDragging ? "ring-primary z-10 opacity-90 shadow-lg ring-2" : "" } ${isHidden ? "opacity-60" : ""}`} > @@ -523,7 +523,7 @@ function SortableTagRow({ className="text-muted-foreground h-4 w-4 shrink-0" aria-hidden="true" /> - + {name} {tag.kind === "RATED" && ( @@ -536,106 +536,110 @@ function SortableTagRow({ {t("mood.manage.usageCount", { count: String(tag.usageCount) })} )} - - - - - - - - - {tag.custom && ( - onEdit(tag)}> - - {t("common.edit")} - + {/* The action cluster wraps to its own line on a narrow row so the + tag name keeps a readable floor instead of truncating away. */} +
    + + + + + + + + + {tag.custom && ( + onEdit(tag)}> + + {t("common.edit")} + + )} + + + + {t("mood.manage.moveToGroup")} + + + + {groupOptions + .filter((group) => group.key !== groupKey) + .map((group) => ( + onMoveToGroup(tag, group.key)} + > + {group.name} + + ))} + + + + {tag.custom && ( + onToggleVisibility(tag)}> + + {t("mood.manage.archive")} + + )} + + +
    ); } diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index 772f902f6..fc8f92199 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -43,6 +43,7 @@ import { Loader2, Pencil, Plus, + RefreshCw, Smile, Trash2, ChevronLeft, @@ -51,6 +52,11 @@ import { } from "lucide-react"; import { useCallback, useEffect, useId, useRef, useState } from "react"; import { createPortal } from "react-dom"; +import { useTableSort } from "@/hooks/use-table-sort"; + +// Columns that open descending when first selected. The logged-at column +// reads newest-first by default; every other column opens ascending. +const MOOD_DESC_COLUMNS: ReadonlySet = new Set(["moodLoggedAt"]); import { toast } from "sonner"; import { formatDateTime } from "@/lib/format"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; @@ -128,8 +134,16 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { const [fromDay, setFromDayRaw] = useState(""); const [toDay, setToDayRaw] = useState(""); const [page, setPage] = useState(1); - const [sortBy, setSortBy] = useState("moodLoggedAt"); - const [sortDir, setSortDir] = useState<"asc" | "desc">("desc"); + // Shared column-sort state (moodLoggedAt opens descending). + const { + sortBy, + sortDir, + toggleSort: applySort, + } = useTableSort({ + defaultColumn: "moodLoggedAt", + defaultDir: "desc", + descColumns: MOOD_DESC_COLUMNS, + }); // v1.15.13 — page-scoped multi-select selection (current page ids only). const [selectedIds, setSelectedIds] = useState>(() => new Set()); @@ -201,13 +215,10 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { clearSelection(); }; + // Compose the page-reset + selection-clear side effects around the + // shared sort toggle. function toggleSort(column: string) { - if (sortBy === column) { - setSortDir((d) => (d === "asc" ? "desc" : "asc")); - } else { - setSortBy(column); - setSortDir(column === "moodLoggedAt" ? "desc" : "asc"); - } + applySort(column); setPage(1); clearSelection(); } @@ -219,7 +230,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { const toParam = toDay || undefined; const sourceParam = sourceFilter === "ALL" ? undefined : sourceFilter; - const { data, isLoading } = useQuery({ + const { data, isLoading, isError, refetch } = useQuery({ queryKey: queryKeys.moodEntriesList({ mood: moodFilter === "ALL" ? undefined : moodFilter, source: sourceParam, @@ -492,6 +503,24 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
    + ) : isError ? ( +
    + {t("mood.loadError")} + +
    ) : !data?.entries?.length ? ( // v1.4.15 phase-C5: replace bare-text empty rectangle with // EmptyState. Filter-aware copy splits "no mood entries yet" diff --git a/src/components/records/allergy-form.tsx b/src/components/records/allergy-form.tsx new file mode 100644 index 000000000..2d6393b09 --- /dev/null +++ b/src/components/records/allergy-form.tsx @@ -0,0 +1,282 @@ +"use client"; + +import { useId, useState } from "react"; +import { createPortal } from "react-dom"; +import { Loader2 } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { ApiError, apiPost, apiPut } from "@/lib/api/api-fetch"; +import { + allergyCategoryEnum, + allergySeverityEnum, + allergyStatusEnum, + allergyTypeEnum, +} from "@/lib/validations/allergy"; +import { useTranslations } from "@/lib/i18n/context"; + +import type { AllergyDTO } from "@/lib/records/dto"; + +const TEXT_MAX_LENGTH = 2000; +/** Select sentinel for the optional "not assessed" severity. */ +const SEVERITY_NONE = "NONE"; + +interface AllergyFormProps { + /** When set the form edits this record; otherwise it creates a new one. */ + existing?: AllergyDTO; + onSuccess?: (saved: AllergyDTO) => void; + onCancel?: () => void; + /** The sheet footer slot the Cancel / Save row portals into when present. */ + footerSlot?: HTMLElement | null; +} + +/** ISO date input (YYYY-MM-DD) → start-of-day UTC instant, or undefined. */ +function toInstant(value: string): string | undefined { + if (!value) return undefined; + const d = new Date(`${value}T00:00:00.000Z`); + return Number.isNaN(d.getTime()) ? undefined : d.toISOString(); +} + +/** + * v1.25 (W-RECORDS) — create or edit a structured allergy/intolerance record. + * + * `substance` is the queryable label; the free-text reaction + note are + * encrypted server-side. Patient-reported reference data, not a clinical + * diagnosis. + */ +export function AllergyForm({ + existing, + onSuccess, + onCancel, + footerSlot, +}: AllergyFormProps) { + const { t } = useTranslations(); + const formId = useId(); + + const [substance, setSubstance] = useState(existing?.substance ?? ""); + const [category, setCategory] = useState(existing?.category ?? "OTHER"); + const [type, setType] = useState(existing?.type ?? "ALLERGY"); + const [severity, setSeverity] = useState(existing?.severity ?? SEVERITY_NONE); + const [status, setStatus] = useState(existing?.status ?? "ACTIVE"); + const [onsetDate, setOnsetDate] = useState( + existing?.onsetAt ? existing.onsetAt.slice(0, 10) : "", + ); + const [reaction, setReaction] = useState(existing?.reaction ?? ""); + const [note, setNote] = useState(existing?.note ?? ""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + + if (!substance.trim()) { + setError(t("records.allergies.form.requiredError")); + return; + } + + const body = { + substance: substance.trim(), + category, + type, + severity: severity === SEVERITY_NONE ? null : severity, + status, + onsetAt: onsetDate ? (toInstant(onsetDate) ?? null) : null, + reaction: reaction.trim() ? reaction.trim() : null, + note: note.trim() ? note.trim() : null, + }; + + setSubmitting(true); + try { + const saved = existing + ? await apiPut(`/api/allergies/${existing.id}`, body) + : await apiPost("/api/allergies", body); + toast.success( + existing + ? t("records.allergies.form.updatedToast") + : t("records.allergies.form.savedToast"), + ); + onSuccess?.(saved); + } catch (err) { + const message = + err instanceof ApiError + ? err.message + : t("records.allergies.form.saveError"); + setError(message); + } finally { + setSubmitting(false); + } + } + + const footerNode = ( + <> + {onCancel ? ( + + ) : null} + + + ); + + return ( +
    +
    +
    + + setSubstance(e.target.value)} + placeholder={t("records.allergies.form.substancePlaceholder")} + maxLength={160} + autoFocus + required + /> +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + setOnsetDate(e.target.value)} + /> +
    +
    + +