diff --git a/.gitleaks.toml b/.gitleaks.toml
index 6905bb139..1b98529ce 100644
--- a/.gitleaks.toml
+++ b/.gitleaks.toml
@@ -25,6 +25,9 @@ regexes = [
# i18n caption keys for the briefing-driven trend charts are dotted message
# keys, not secrets — one trips the generic-api-key entropy heuristic.
'''insights\.trendsRow\.caption''',
+ # Coach cadence-suggestion label keys are dotted message keys, not secrets;
+ # one trips the generic-api-key entropy heuristic.
+ '''coach\.reminderSuggestion\.cadence''',
]
# Specific commit fingerprints from prior false-positive findings (kept for reproducibility).
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 89c749534..d35817d4f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,42 @@
## [Unreleased]
+## [1.18.1] — 2026-06-16 — labs that hold a catalog, a condition journal, and reminders that only nudge when you forget
+
+A large release that turns two thin features into proper ones and adds two more, all in the existing visual language. Lab results gain a biomarker catalog: define a marker once with its unit and reference range, then log a value by picking it — with a full dashboard-style chart, edit and undo. A new condition journal records an illness from onset to recovery and, once it has enough of your own history, reflects back how it announced itself and how long recovery took — retrospective only, never a diagnosis. Preventive-care reminders now fire only when a measurement is actually overdue and clear themselves the moment a reading arrives from any source, and the coach can suggest an evidence-based measurement cadence you accept with one tap — both feeding one shared reminder engine. The settings menu, the dashboard header, the recovery page and the coach view were tightened so every module reads as one app. Medications can now be switched off like any other module; the core vitals — weight, blood pressure, pulse — stay always on. No breaking changes.
+
+### Added
+
+- **Lab biomarkers with a reference range you set once.** Define a biomarker — name, unit, lower and upper bound, an optional note — from a suggested common panel or your own, then record a value by picking the marker instead of retyping its range every time. Each biomarker gets a proper chart with its target band, a full reading history, and edit, correct and undo. Every reading links to a catalog marker, so none is a dead end.
+- **A condition journal.** Track any illness or condition — acute, chronic, recurring, or a flare hanging off an earlier one — with a daily symptom-and-severity log and an encrypted note. Once there is enough of your own data, a retrospective view shows how often you have been unwell and your typical recovery gap (when your body returned to baseline versus when you felt better), computed from your own baseline and withheld until it is sure. It never predicts or diagnoses, and a sustained red-flag pattern points you to care rather than reassuring you. The area ships switched off; turn it on under "What you track".
+- **Rest Mode.** While a condition is active, your scores, recovery and streaks are annotated rather than penalised and cadence nudges pause — an active illness never changes a measured number, only the narrative around it. A calm banner explains it on the dashboard and recovery view, and condition episodes carry into the doctor-report PDF and the FHIR export as patient-reported conditions.
+- **Coach measurement-cadence suggestions.** When a real change warrants it, the coach can suggest an evidence-based cadence — daily weight, morning-and-evening blood pressure for a week, structured glucose — that you accept with one tap to create a reminder. It stays non-naggy: capped, cooled-down, dismissible, and willing to say you already measure enough.
+
+### Changed
+
+- **Preventive-care reminders only nudge when you forget.** A reminder now clears itself the moment a matching measurement arrives — entered by hand or synced from a device — and reschedules its next due date, so the self-disciplined are left alone and the reminder is a safety net for gaps. Coach suggestions and preventive-care reminders run on one shared engine; reminders can be edited, carry a quiet "Coach" badge and course-window end date, and the linkable measurement set now covers the full range of types.
+- **Every module reads as one app.** Labs, the condition journal and preventive care now use the same add-entry sheet, overflow menus and confirm-before-delete guards as medications. The settings sections drop their leading blurbs and align consistently, integrations split into Connections, Channels and Sources as their own entries, the coach setting reads as "activate the coach" (on by default), and the "About me" health context moved into the account profile.
+- **A calmer dashboard and a consistent recovery page.** The daily-overview header now sits flat alongside the other tiles instead of as a glowing hero. The recovery page follows the same shape as every other metric page — heading, a short explanation, history with high, median and mean, a chart with a 7- and 30-point view, a target band and an assessment — and the duplicate score block was removed. The coach view was rebalanced for symmetry and space.
+- **Medications is now an optional module.** It can be switched off like cycle, sleep or labs; weight, blood pressure and pulse remain the always-on core.
+- **A richer cycle log.** The day sheet gains a phase-context header, inline field explanations, and a fertility section that opens itself when your goal calls for it.
+- **Admin tidy-up.** The coach feedback, insight-quality and assistant-surface panels consolidate into one coach area, the backups panel leads with its counts, and the danger zone now requires typing a confirmation like the less-destructive restore already did.
+
+### Fixed
+
+- The illness recovery-gap engine no longer reports a return before a vital deviated, escalates a red-flag for rock-steady oxygen or temperature, reads journalled fever, and keys days by your own time zone rather than UTC.
+- Lab readings logged without a catalog marker are linked on write instead of stranded; a failed note-load no longer wipes the note on edit; and a partial reference-range edit can no longer save an inverted range.
+- The morning-and-evening blood-pressure protocol now schedules both times instead of one. Reminder satisfaction is race-safe across the cron and event paths.
+
+### Security
+
+- The encryption-key rotation script now covers every encrypted column in the schema — including the new lab, biomarker and condition notes and a tail of previously-missed integration and profile fields — driven from a single registry that a test asserts is complete, so a future encrypted column cannot silently miss rotation. The admin data-wipe is gated behind a typed confirmation and a per-user rate limit.
+- Pin `hono` to `≥ 4.12.25` through an override, clearing the CORS-middleware advisory (GHSA-88fw-hqm2-52qc). The package is pulled in only by Prisma's and the shadcn CLI's tooling; the app is Next.js and sets no CORS headers anywhere, so the issue was never reachable at runtime.
+- Pin `picomatch` to `≥ 4.0.4` through an override, clearing the ReDoS advisory (CVE-2026-33671). The package is a transitive build dependency.
+
+### Operator note
+
+- Additive migrations only (`0169`–`0172`): reminder course-window fields, the biomarker catalog and lab link, the condition-journal tables, and a partial unique index for coach-minted reminders. No backfill and no deploy-time action required. The condition journal ships disabled by default. **If you rotate `ENCRYPTION_KEYS`, re-run `scripts/rotate-encryption-key.ts` after upgrading** — it now re-encrypts several columns it previously skipped; do not drop a legacy key until it reports zero remaining rows.
+
## [1.18.0] — 2026-06-16 — turn off what you don't track, and a settings menu that finally reads as one app
This release makes the app yours to shape. Every optional area — cycle, mood, sleep, glucose, workouts, recovery, labs, achievements, the coach, AI insights and the doctor-report — can be turned off, and when it is, it disappears everywhere at once: out of the navigation, off the dashboard, gone from insights, the coach, your reminders, achievements and the exported report. Your data is kept; turn the area back on and it returns. The settings menu was redesigned alongside it, so notification channels, integrations, sources and per-area pages each sit where you'd look for them instead of spread across overlapping hubs. The core vitals — weight, blood pressure, pulse and medications — are always on. No breaking changes.
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index af8ba7573..e43741a6b 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.18.0
+ version: 1.18.1
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -41,6 +41,7 @@ tags:
- name: Cycle
- name: MeasurementReminders
- name: Labs
+ - name: Illness
- name: Admin
- name: Meta
paths:
@@ -3041,6 +3042,28 @@ paths:
- medication
- mobility
- environment
+ reminderSuggestions:
+ type: object
+ properties:
+ enabled:
+ default: true
+ type: boolean
+ stopped:
+ default: false
+ type: boolean
+ dismissedCadences:
+ default: []
+ maxItems: 32
+ type: array
+ items:
+ type: string
+ maxLength: 64
+ lastSuggestedAt:
+ default: null
+ anyOf:
+ - type: string
+ maxLength: 40
+ - type: "null"
responses:
"200":
description: Saved preferences echoed back.
@@ -4364,6 +4387,59 @@ paths:
"401": *a1
"422": *a3
"429": *a4
+ /api/coach/reminder-suggestions:
+ post:
+ tags:
+ - Insights
+ summary: Act on a Coach cadence suggestion
+ description: 'v1.18.1 — accept / dismiss / stop a Coach-proposed measurement cadence. Coach-gated
+ (`requireModuleEnabled("coach")`); a disabled surface 403s. `accept` resolves the cadence from the closed
+ server-side catalog and creates a `MeasurementReminder` with `origin: COACH` (201), or returns 200 with
+ `duplicate: true` when a live COACH reminder for that metric already exists — idempotent against a re-tapped or
+ stale card, with a partial unique index as the structural backstop. Per-user rate-limited (429 on excess). Auth
+ via cookie or Bearer; the owner is narrowed from the session.'
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ cadenceId:
+ type: string
+ minLength: 1
+ maxLength: 64
+ action:
+ type: string
+ enum:
+ - accept
+ - dismiss
+ - stop
+ required:
+ - cadenceId
+ - action
+ responses:
+ "200":
+ description: "A dismiss/stop, or an accept that matched an existing reminder (`duplicate: true`)."
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/CoachReminderSuggestionResultOk"
+ "201":
+ description: An accept that created a new reminder.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/CoachReminderSuggestionResultCreated"
+ "401": *a1
+ "403":
+ description: Coach surface (or the cadence's module) disabled.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "422": *a3
+ "429": *a4
/api/admin/invites:
get:
tags:
@@ -4905,6 +4981,14 @@ paths:
- STAIR_ASCENT_SPEED
- STAIR_DESCENT_SPEED
- BREATHING_DISTURBANCES
+ - SLEEP_SCORE
+ - ANS_CHARGE
+ - DAY_STRAIN
+ - WORKOUT_STRAIN
+ - CARDIO_LOAD
+ - AVERAGE_HEART_RATE
+ - MAX_HEART_RATE
+ - ENERGY_EXPENDITURE_KJ
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
@@ -5690,6 +5774,12 @@ paths:
date-range filters. Each row carries a server-computed neutral `rangeStatus` and a `hasNote` flag (the encrypted
note itself is not echoed).
parameters:
+ - in: query
+ name: biomarkerId
+ schema:
+ type: string
+ minLength: 1
+ maxLength: 64
- in: query
name: analyte
schema:
@@ -5759,6 +5849,10 @@ paths:
schema:
type: object
properties:
+ biomarkerId:
+ type: string
+ minLength: 1
+ maxLength: 64
panel:
anyOf:
- type: string
@@ -5791,9 +5885,7 @@ paths:
- type: string
const: ""
required:
- - analyte
- value
- - unit
- takenAt
responses:
"201":
@@ -5924,6 +6016,636 @@ paths:
"404": *a8
"422": *a3
"429": *a4
+ /api/labs/restore:
+ post:
+ tags:
+ - Labs
+ summary: Restore soft-deleted lab results
+ description: Un-tombstones 1..200 of the caller's soft-deleted lab results in one pass (the delete-Undo affordance). A
+ foreign / live id is a silent no-op. Audits via the wide-event ledger.
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/RestoreLabResultsRequest"
+ responses:
+ "200":
+ description: Restore count.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/RestoreLabResultsResponse"
+ "401": *a1
+ "422": *a3
+ "429": *a4
+ /api/biomarkers:
+ get:
+ tags:
+ - Labs
+ summary: List the caller's biomarker catalog
+ description: Returns every biomarker the caller has defined, name-ordered. Each carries its unit, reference bounds, and
+ decrypted context.
+ responses:
+ "200":
+ description: Biomarker catalog.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ListBiomarkersEnvelope"
+ "401": *a1
+ "422": *a3
+ "429": *a4
+ post:
+ tags:
+ - Labs
+ summary: Define a biomarker
+ description: Creates a user-scoped biomarker. The optional context note is AES-256-GCM encrypted before write. Audits as
+ `biomarker.create`.
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ name:
+ type: string
+ minLength: 1
+ maxLength: 120
+ unit:
+ type: string
+ minLength: 1
+ maxLength: 40
+ lowerBound:
+ type: number
+ upperBound:
+ type: number
+ context:
+ type: string
+ maxLength: 2000
+ panel:
+ anyOf:
+ - type: string
+ minLength: 1
+ maxLength: 120
+ - type: string
+ const: ""
+ required:
+ - name
+ - unit
+ responses:
+ "201":
+ description: Created biomarker.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/CreateBiomarkerResponse"
+ "401": *a1
+ "409": &a10
+ description: A biomarker with this name already exists for the caller.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "422": *a3
+ "429": *a4
+ /api/biomarkers/{id}:
+ get:
+ tags:
+ - Labs
+ summary: Fetch a single biomarker
+ description: Returns the biomarker including its decrypted `context`. Cross-user rows surface as 404.
+ parameters:
+ - in: path
+ name: id
+ schema:
+ type: string
+ required: true
+ responses:
+ "200":
+ description: Biomarker detail.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/GetBiomarkerResponse"
+ "401": *a1
+ "404": &a9
+ description: Biomarker not found (or owned by another user).
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "422": *a3
+ "429": *a4
+ put:
+ tags:
+ - Labs
+ summary: Edit a biomarker
+ description: Partial edit; omitted fields are untouched, an explicit null clears `context` / `panel` / a bound. Audits
+ as `biomarker.update`.
+ parameters:
+ - in: path
+ name: id
+ schema:
+ type: string
+ required: true
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ name:
+ type: string
+ minLength: 1
+ maxLength: 120
+ unit:
+ type: string
+ minLength: 1
+ maxLength: 40
+ lowerBound:
+ anyOf:
+ - type: number
+ - type: "null"
+ upperBound:
+ anyOf:
+ - type: number
+ - type: "null"
+ context:
+ anyOf:
+ - type: string
+ maxLength: 2000
+ - type: "null"
+ panel:
+ anyOf:
+ - anyOf:
+ - type: string
+ minLength: 1
+ maxLength: 120
+ - type: string
+ const: ""
+ - type: "null"
+ responses:
+ "200":
+ description: Updated biomarker.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/UpdateBiomarkerResponse"
+ "401": *a1
+ "404": *a9
+ "409": *a10
+ "422": *a3
+ "429": *a4
+ delete:
+ tags:
+ - Labs
+ summary: Delete a biomarker
+ description: "Hard-deletes the catalog definition. The `onDelete: SetNull` FK unlinks every reading (they keep their
+ legacy `analyte` / `unit`), so the history survives. Audits as `biomarker.delete`."
+ parameters:
+ - in: path
+ name: id
+ schema:
+ type: string
+ required: true
+ responses:
+ "200":
+ description: Deletion succeeded.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DeleteBiomarkerResponse"
+ "401": *a1
+ "404": *a9
+ "422": *a3
+ "429": *a4
+ /api/illness/episodes:
+ get:
+ tags:
+ - Illness
+ summary: List illness episodes (v1.18.1)
+ description: Returns the caller's live (non-deleted) illness/condition episodes, newest-first by onset.
+ `includeResolved=false` hides episodes that already carry a `resolvedAt`. Born-gated.
+ parameters:
+ - in: query
+ name: limit
+ schema:
+ type: integer
+ minimum: 1
+ maximum: 100
+ - in: query
+ name: includeResolved
+ schema:
+ anyOf:
+ - type: string
+ const: "true"
+ - type: string
+ const: "false"
+ responses:
+ "200":
+ description: The caller's illness episodes.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ListIllnessEpisodesEnvelope"
+ "401": *a1
+ "422": *a3
+ "429": *a4
+ post:
+ tags:
+ - Illness
+ summary: Open an illness episode (v1.18.1)
+ description: Creates one episode for the caller. `onsetAt` defaults to 'now' when omitted; the optional note is
+ AES-256-GCM encrypted before write. A `parentConditionId` must reference an owned, live episode. Audits as
+ `illness.episode.create`. Born-gated.
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ label:
+ type: string
+ minLength: 1
+ maxLength: 120
+ type:
+ type: string
+ enum:
+ - INFECTION
+ - ALLERGY
+ - INJURY
+ - MENTAL_HEALTH
+ - AUTOIMMUNE
+ - CHRONIC
+ - OTHER
+ lifecycle:
+ default: ACUTE
+ type: string
+ enum:
+ - ACUTE
+ - CHRONIC_ONGOING
+ - RECURRING
+ - FLARE
+ onsetAt:
+ 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)))$
+ resolvedAt:
+ 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"
+ parentConditionId:
+ anyOf:
+ - type: string
+ minLength: 1
+ maxLength: 40
+ - type: "null"
+ note:
+ anyOf:
+ - type: string
+ maxLength: 2000
+ - type: "null"
+ required:
+ - label
+ - type
+ responses:
+ "201":
+ description: Episode created.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/CreateIllnessEpisodeEnvelope"
+ "401": *a1
+ "422": *a3
+ "429": *a4
+ /api/illness/episodes/{id}:
+ get:
+ tags:
+ - Illness
+ summary: Read a single illness episode (v1.18.1)
+ description: Returns the episode including its decrypted `note`. Owner-scoped; a cross-user or tombstoned id 404s. Born-gated.
+ parameters:
+ - in: path
+ name: id
+ schema:
+ type: string
+ required: true
+ responses:
+ "200":
+ description: The episode.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/GetIllnessEpisodeEnvelope"
+ "401": *a1
+ "404": &a11
+ description: Illness episode not found (or owned by another user).
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "422": *a3
+ "429": *a4
+ patch:
+ tags:
+ - Illness
+ summary: Edit an illness episode (v1.18.1)
+ description: Partial edit; omitted fields are left untouched. A re-parent must point at an owned, live episode (and
+ never the episode itself). Audits as `illness.episode.update`. Owner-scoped + born-gated.
+ parameters:
+ - in: path
+ name: id
+ schema:
+ type: string
+ required: true
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ label:
+ type: string
+ minLength: 1
+ maxLength: 120
+ type:
+ type: string
+ enum:
+ - INFECTION
+ - ALLERGY
+ - INJURY
+ - MENTAL_HEALTH
+ - AUTOIMMUNE
+ - CHRONIC
+ - OTHER
+ lifecycle:
+ type: string
+ enum:
+ - ACUTE
+ - CHRONIC_ONGOING
+ - RECURRING
+ - FLARE
+ onsetAt:
+ 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)))$
+ resolvedAt:
+ 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"
+ parentConditionId:
+ anyOf:
+ - type: string
+ minLength: 1
+ maxLength: 40
+ - type: "null"
+ note:
+ anyOf:
+ - type: string
+ maxLength: 2000
+ - type: "null"
+ additionalProperties: false
+ responses:
+ "200":
+ description: Episode updated.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/UpdateIllnessEpisodeEnvelope"
+ "401": *a1
+ "404": *a11
+ "422": *a3
+ "429": *a4
+ delete:
+ tags:
+ - Illness
+ summary: Soft-delete an illness episode (v1.18.1)
+ description: "Stamps `deletedAt` (tombstone). Idempotent — a re-delete is a no-op. Returns the `{ deleted: true }`
+ envelope (the cross-module DELETE shape). Audits as `illness.episode.delete`. Owner-scoped + born-gated."
+ parameters:
+ - in: path
+ name: id
+ schema:
+ type: string
+ required: true
+ responses:
+ "200":
+ description: Soft-deleted.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DeleteIllnessEpisodeEnvelope"
+ "401": *a1
+ "404": *a11
+ "422": *a3
+ "429": *a4
+ /api/illness/episodes/{id}/resolve:
+ patch:
+ tags:
+ - Illness
+ summary: Mark an illness episode recovered (v1.18.1)
+ description: Stamps `resolvedAt` (defaults to 'now' when the body omits it). A CHRONIC_ONGOING episode has no recovery
+ date and 422s with `errorCode:"illness.episode.chronic-no-resolve"`. Audits as `illness.episode.resolve`.
+ Owner-scoped + born-gated.
+ parameters:
+ - in: path
+ name: id
+ schema:
+ type: string
+ required: true
+ requestBody:
+ required: false
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ resolvedAt:
+ 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)))$
+ responses:
+ "200":
+ description: Episode resolved.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ResolveIllnessEpisodeEnvelope"
+ "401": *a1
+ "404": *a11
+ "422": *a3
+ "429": *a4
+ /api/illness/episodes/{id}/day-logs:
+ get:
+ tags:
+ - Illness
+ summary: Read one day-log for an episode (v1.18.1)
+ description: Returns the day-log for the episode + `date`, or `null` when nothing is logged that day (lets the log-day
+ sheet pre-fill). The parent episode must be owned + live. Born-gated.
+ parameters:
+ - in: path
+ name: id
+ schema:
+ type: string
+ required: true
+ - in: query
+ name: date
+ schema:
+ type: string
+ pattern: ^\d{4}-\d{2}-\d{2}$
+ required: true
+ responses:
+ "200":
+ description: The day-log, or null.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/GetIllnessDayLogEnvelope"
+ "401": *a1
+ "404": *a11
+ "422": *a3
+ "429": *a4
+ post:
+ tags:
+ - Illness
+ summary: Upsert an episode day-log (v1.18.1)
+ description: "Upserts the day's symptom / functional-impact / fever timeline row on `(episodeId, date)`: 201 on insert,
+ 200 on update. The parent episode must be owned + live; the optional note is encrypted at rest. Audits as
+ `illness.day-log.upsert`. Born-gated."
+ parameters:
+ - in: path
+ name: id
+ schema:
+ type: string
+ required: true
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ date:
+ type: string
+ pattern: ^\d{4}-\d{2}-\d{2}$
+ functionalImpact:
+ anyOf:
+ - type: integer
+ minimum: 0
+ maximum: 3
+ - type: "null"
+ feverC:
+ anyOf:
+ - type: number
+ minimum: 30
+ maximum: 45
+ - type: "null"
+ symptoms:
+ maxItems: 40
+ type: array
+ items:
+ type: object
+ properties:
+ key:
+ type: string
+ minLength: 1
+ maxLength: 80
+ severity:
+ type: integer
+ minimum: 1
+ maximum: 3
+ required:
+ - key
+ note:
+ anyOf:
+ - type: string
+ maxLength: 2000
+ - type: "null"
+ loggedAt:
+ type: string
+ format: date-time
+ pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
+ required:
+ - date
+ responses:
+ "200":
+ description: Existing day-log updated.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/UpsertIllnessDayLogUpdatedEnvelope"
+ "201":
+ description: New day-log created.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/UpsertIllnessDayLogCreatedEnvelope"
+ "401": *a1
+ "404": *a11
+ "422": *a3
+ "429": *a4
+ /api/illness/episodes/{id}/correlation:
+ get:
+ tags:
+ - Illness
+ summary: Per-episode retrospective correlation (v1.18.1)
+ description: "Returns the coverage-gated `Derived` correlation for one episode: the pre-onset anomaly scan, the
+ nadir, per-vital physiological returns, the headline recovery-gap, and any red flags. The findings derive from
+ the user's OWN baseline (median ± MAD) over a contamination-guarded window — never a population constant.
+ `status:\"insufficient\"` carries coverage + a reason. Retrospective ONLY — never a predictor or diagnoser.
+ Owner-scoped + born-gated."
+ parameters:
+ - in: path
+ name: id
+ schema:
+ type: string
+ required: true
+ responses:
+ "200":
+ description: The correlation Derived DTO (ok or insufficient).
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/IllnessCorrelationEnvelope"
+ "401": *a1
+ "404": *a11
+ "422": *a3
+ "429": *a4
+ /api/illness/insights:
+ get:
+ tags:
+ - Illness
+ summary: Cross-episode retrospective insights (v1.18.1)
+ description: "Returns the cross-episode retrospective summary over a trailing window: 'sick N times · typical recovery
+ gap X days', a recurrence-by-month tally, and a per-type breakdown. The typical gap is withheld (null) below the
+ min-sample floor (asserts nothing thin). Retrospective ONLY — the recurrence figure is a count of the past,
+ never a forecast. Born-gated + owner-scoped."
+ parameters:
+ - in: query
+ name: windowDays
+ schema:
+ type: integer
+ minimum: 30
+ maximum: 1095
+ responses:
+ "200":
+ description: The cross-episode retrospective summary.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/IllnessInsightsEnvelope"
+ "401": *a1
+ "422": *a3
+ "429": *a4
components:
schemas:
PasskeyLoginVerifyRequest:
@@ -6834,10 +7556,14 @@ components:
type: boolean
labs:
type: boolean
+ illness:
+ type: boolean
achievements:
type: boolean
insights:
type: boolean
+ medications:
+ type: boolean
doctorReport:
type: boolean
additionalProperties: false
@@ -7184,10 +7910,19 @@ components:
- PULSE
- BLOOD_GLUCOSE
- OXYGEN_SATURATION
- - BODY_FAT
- BODY_TEMPERATURE
- description: Auto-resolve target metric. BP resolves on BLOOD_PRESSURE_SYS (the SYS row is the 'a BP was measured'
- sentinel). Omit for a free-text Vorsorge that resolves only on a manual satisfy.
+ - BODY_FAT
+ - FAT_MASS
+ - FAT_FREE_MASS
+ - MUSCLE_MASS
+ - LEAN_BODY_MASS
+ - BONE_MASS
+ - TOTAL_BODY_WATER
+ - VISCERAL_FAT
+ - BODY_MASS_INDEX
+ description: Auto-resolve target metric (vitals + body-composition family). BP resolves on BLOOD_PRESSURE_SYS (the SYS
+ row is the 'a BP was measured' sentinel). Omit for a free-text Vorsorge that resolves only on a manual satisfy
+ or a matching lab result.
MeasurementReminderUpdate:
type: object
properties:
@@ -7230,6 +7965,17 @@ components:
type: boolean
description: Partial edit of a Vorsorge reminder. Omitted fields are left untouched; nextDueAt is recomputed server-side
after the patch applies.
+ RestoreLabResultsRequest:
+ type: object
+ properties:
+ ids:
+ minItems: 1
+ maxItems: 200
+ type: array
+ items:
+ type: string
+ required:
+ - ids
MedicationTreatmentClass:
type: string
enum:
@@ -12218,6 +12964,34 @@ components:
- medication
- mobility
- environment
+ reminderSuggestions:
+ type: object
+ properties:
+ enabled:
+ default: true
+ type: boolean
+ stopped:
+ default: false
+ type: boolean
+ dismissedCadences:
+ default: []
+ maxItems: 32
+ type: array
+ items:
+ type: string
+ maxLength: 64
+ lastSuggestedAt:
+ default: null
+ anyOf:
+ - type: string
+ maxLength: 40
+ - type: "null"
+ required:
+ - enabled
+ - stopped
+ - dismissedCadences
+ - lastSuggestedAt
+ additionalProperties: false
required:
- tone
- verbosity
@@ -12302,6 +13076,34 @@ components:
- medication
- mobility
- environment
+ reminderSuggestions:
+ type: object
+ properties:
+ enabled:
+ default: true
+ type: boolean
+ stopped:
+ default: false
+ type: boolean
+ dismissedCadences:
+ default: []
+ maxItems: 32
+ type: array
+ items:
+ type: string
+ maxLength: 64
+ lastSuggestedAt:
+ default: null
+ anyOf:
+ - type: string
+ maxLength: 40
+ - type: "null"
+ required:
+ - enabled
+ - stopped
+ - dismissedCadences
+ - lastSuggestedAt
+ additionalProperties: false
required:
- tone
- verbosity
@@ -12404,6 +13206,9 @@ components:
labs:
type: boolean
description: Whether the "labs" module is enabled for this account.
+ illness:
+ type: boolean
+ description: Whether the "illness" module is enabled for this account.
achievements:
type: boolean
description: Whether the "achievements" module is enabled for this account.
@@ -12413,6 +13218,9 @@ components:
insights:
type: boolean
description: Whether the "insights" module is enabled for this account.
+ medications:
+ type: boolean
+ description: Whether the "medications" module is enabled for this account.
doctorReport:
type: boolean
description: Whether the "doctorReport" module is enabled for this account.
@@ -12424,9 +13232,11 @@ components:
- workouts
- recovery
- labs
+ - illness
- achievements
- coach
- insights
+ - medications
- doctorReport
additionalProperties: false
description: Fully-resolved per-user module enable/disable map. Every toggleable module key is present. `false` means
@@ -14570,6 +15380,67 @@ components:
- data
- error
additionalProperties: false
+ CoachReminderSuggestionResultOk:
+ type: object
+ properties:
+ data:
+ $ref: "#/components/schemas/CoachReminderSuggestionResult"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ CoachReminderSuggestionResult:
+ type: object
+ properties:
+ ok:
+ type: boolean
+ const: true
+ action:
+ type: string
+ enum:
+ - accept
+ - dismiss
+ - stop
+ reminder:
+ description: "On `accept` of a fresh suggestion: the created MeasurementReminder DTO. Null when the action was a
+ prefs-only dismiss/stop, or when an accept hit the structural dedup (`duplicate: true`)."
+ anyOf:
+ - {}
+ - type: "null"
+ duplicate:
+ description: True when an `accept` matched an already-live COACH reminder for the same metric (idempotent no-op).
+ type: boolean
+ required:
+ - ok
+ - action
+ additionalProperties: false
+ description: "Outcome of a Coach cadence-suggestion action. `accept` returns 201 with the created reminder (or 200 +
+ `duplicate: true` when one already exists); `dismiss`/`stop` return 200."
+ CoachReminderSuggestionResultCreated:
+ type: object
+ properties:
+ data:
+ $ref: "#/components/schemas/CoachReminderSuggestionResult"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
AdminInviteList:
type: object
properties:
@@ -16932,6 +17803,17 @@ components:
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- type: "null"
+ endsOn:
+ 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"
+ origin:
+ type: string
+ enum:
+ - VORSORGE
+ - COACH
notifyHour:
type: integer
minimum: -9007199254740991
@@ -16969,6 +17851,8 @@ components:
- intervalDays
- rrule
- anchorDate
+ - endsOn
+ - origin
- notifyHour
- location
- nextDueAt
@@ -16978,7 +17862,9 @@ components:
- updatedAt
additionalProperties: false
description: A Vorsorge reminder. nextDueAt is server-computed (server-authoritative). A free-text reminder carries
- measurementType=null and resolves only on a manual satisfy.
+ measurementType=null and resolves only on a manual satisfy. origin distinguishes a user-created (VORSORGE)
+ reminder from one minted by a Coach cadence suggestion (COACH); endsOn bounds a finite course window (null =
+ open-ended).
MeasurementReminderCreatedEnvelope:
type: object
properties:
@@ -17117,6 +18003,10 @@ components:
properties:
id:
type: string
+ biomarkerId:
+ anyOf:
+ - type: string
+ - type: "null"
panel:
anyOf:
- type: string
@@ -17149,6 +18039,7 @@ components:
type: string
required:
- id
+ - biomarkerId
- panel
- analyte
- value
@@ -17162,8 +18053,10 @@ components:
- createdAt
- updatedAt
additionalProperties: false
- description: A stored lab result. The encrypted note is never echoed in list rows; `hasNote` flags its presence and the
- single-resource GET returns the decrypted `note`.
+ description: A stored lab result. `biomarkerId` links the user-scoped catalog marker (null for legacy free-text rows);
+ when linked, `analyte` / `unit` / `referenceLow` / `referenceHigh` are RESOLVED server-side from the Biomarker —
+ the client renders them and never recomputes the range. The encrypted note is never echoed in list rows;
+ `hasNote` flags its presence and the single-resource GET returns the decrypted `note`.
LabReferenceRangeStatus:
type: string
enum:
@@ -17213,6 +18106,10 @@ components:
properties:
id:
type: string
+ biomarkerId:
+ anyOf:
+ - type: string
+ - type: "null"
panel:
anyOf:
- type: string
@@ -17247,6 +18144,7 @@ components:
- type: "null"
required:
- id
+ - biomarkerId
- panel
- analyte
- value
@@ -17301,6 +18199,742 @@ components:
- data
- error
additionalProperties: false
+ RestoreLabResultsResponse:
+ type: object
+ properties:
+ data:
+ type: object
+ properties:
+ restored:
+ type: number
+ required:
+ - restored
+ additionalProperties: false
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ ListBiomarkersEnvelope:
+ type: object
+ properties:
+ data:
+ $ref: "#/components/schemas/ListBiomarkersResponse"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ ListBiomarkersResponse:
+ type: object
+ properties:
+ biomarkers:
+ type: array
+ items:
+ $ref: "#/components/schemas/Biomarker"
+ required:
+ - biomarkers
+ additionalProperties: false
+ Biomarker:
+ type: object
+ properties:
+ id:
+ type: string
+ name:
+ type: string
+ unit:
+ type: string
+ lowerBound:
+ anyOf:
+ - type: number
+ - type: "null"
+ upperBound:
+ anyOf:
+ - type: number
+ - type: "null"
+ panel:
+ anyOf:
+ - type: string
+ - type: "null"
+ hasContext:
+ type: boolean
+ context:
+ anyOf:
+ - type: string
+ - type: "null"
+ createdAt:
+ type: string
+ updatedAt:
+ type: string
+ required:
+ - id
+ - name
+ - unit
+ - lowerBound
+ - upperBound
+ - panel
+ - hasContext
+ - context
+ - createdAt
+ - updatedAt
+ additionalProperties: false
+ description: A user-scoped catalog marker. `context` is the decrypted per-marker note (or null); `hasContext` flags its
+ presence. A `LabResult` linking this marker resolves its unit + reference bounds from here.
+ CreateBiomarkerResponse:
+ type: object
+ properties:
+ data:
+ $ref: "#/components/schemas/Biomarker"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ GetBiomarkerResponse:
+ type: object
+ properties:
+ data:
+ $ref: "#/components/schemas/Biomarker"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ UpdateBiomarkerResponse:
+ type: object
+ properties:
+ data:
+ $ref: "#/components/schemas/Biomarker"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ DeleteBiomarkerResponse:
+ 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
+ ListIllnessEpisodesEnvelope:
+ type: object
+ properties:
+ data:
+ type: array
+ items:
+ $ref: "#/components/schemas/IllnessEpisode"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ IllnessEpisode:
+ type: object
+ properties:
+ id:
+ type: string
+ label:
+ type: string
+ type:
+ type: string
+ enum:
+ - INFECTION
+ - ALLERGY
+ - INJURY
+ - MENTAL_HEALTH
+ - AUTOIMMUNE
+ - CHRONIC
+ - OTHER
+ lifecycle:
+ type: string
+ enum:
+ - ACUTE
+ - CHRONIC_ONGOING
+ - RECURRING
+ - FLARE
+ onsetAt:
+ type: string
+ resolvedAt:
+ anyOf:
+ - type: string
+ - type: "null"
+ parentConditionId:
+ anyOf:
+ - type: string
+ - type: "null"
+ note:
+ anyOf:
+ - type: string
+ - type: "null"
+ createdAt:
+ type: string
+ updatedAt:
+ type: string
+ required:
+ - id
+ - label
+ - type
+ - lifecycle
+ - onsetAt
+ - resolvedAt
+ - parentConditionId
+ - note
+ - createdAt
+ - updatedAt
+ additionalProperties: false
+ description: A stored illness/condition episode. `note` is the decrypted free-text (or null on a key-rotation gap —
+ fail-soft, never 500). `resolvedAt` is null while the episode is open. `parentConditionId` links a
+ FLARE/RECURRING bout to its parent condition.
+ CreateIllnessEpisodeEnvelope:
+ type: object
+ properties:
+ data:
+ $ref: "#/components/schemas/IllnessEpisode"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ GetIllnessEpisodeEnvelope:
+ type: object
+ properties:
+ data:
+ $ref: "#/components/schemas/IllnessEpisode"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ UpdateIllnessEpisodeEnvelope:
+ type: object
+ properties:
+ data:
+ $ref: "#/components/schemas/IllnessEpisode"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ DeleteIllnessEpisodeEnvelope:
+ 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
+ ResolveIllnessEpisodeEnvelope:
+ type: object
+ properties:
+ data:
+ $ref: "#/components/schemas/IllnessEpisode"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ GetIllnessDayLogEnvelope:
+ type: object
+ properties:
+ data:
+ anyOf:
+ - $ref: "#/components/schemas/IllnessDayLog"
+ - type: "null"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ IllnessDayLog:
+ type: object
+ properties:
+ id:
+ type: string
+ episodeId:
+ type: string
+ date:
+ type: string
+ functionalImpact:
+ anyOf:
+ - type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ - type: "null"
+ feverC:
+ anyOf:
+ - type: number
+ - type: "null"
+ symptoms:
+ type: array
+ items:
+ $ref: "#/components/schemas/IllnessSymptom"
+ note:
+ anyOf:
+ - type: string
+ - type: "null"
+ updatedAt:
+ type: string
+ required:
+ - id
+ - episodeId
+ - date
+ - functionalImpact
+ - feverC
+ - symptoms
+ - note
+ - updatedAt
+ additionalProperties: false
+ description: A stored day-log for an episode. `date` is the `YYYY-MM-DD` it covers. `functionalImpact` (0–3) and
+ `feverC` are plaintext; `symptoms` flattens the link rows; `note` is the decrypted free-text (or null).
+ IllnessSymptom:
+ type: object
+ properties:
+ key:
+ type: string
+ severity:
+ anyOf:
+ - type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ - type: "null"
+ required:
+ - key
+ - severity
+ additionalProperties: false
+ description: "A symptom link on a day-log: the catalog `key` plus an optional 1–3 graded severity (`null` = a plain
+ presence link; the link's presence already means 'present', so 0 is not a distinct state)."
+ UpsertIllnessDayLogUpdatedEnvelope:
+ type: object
+ properties:
+ data:
+ $ref: "#/components/schemas/IllnessDayLog"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ UpsertIllnessDayLogCreatedEnvelope:
+ type: object
+ properties:
+ data:
+ $ref: "#/components/schemas/IllnessDayLog"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ IllnessCorrelationEnvelope:
+ type: object
+ properties:
+ data:
+ $ref: "#/components/schemas/IllnessCorrelationResponse"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ IllnessCorrelationResponse:
+ type: object
+ properties:
+ episodeId:
+ type: string
+ status:
+ type: string
+ enum:
+ - ok
+ - insufficient
+ value:
+ anyOf:
+ - $ref: "#/components/schemas/IllnessCorrelationValue"
+ - type: "null"
+ coverage:
+ type: object
+ properties:
+ requiredInputs:
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ presentInputs:
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ historyDays:
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ missing:
+ type: array
+ items:
+ type: string
+ required:
+ - requiredInputs
+ - presentInputs
+ - historyDays
+ - missing
+ additionalProperties: false
+ confidence:
+ anyOf:
+ - type: object
+ properties:
+ score:
+ type: number
+ band:
+ type: string
+ required:
+ - score
+ - band
+ additionalProperties: false
+ - type: "null"
+ provenance:
+ type: object
+ properties:
+ inputs:
+ type: array
+ items:
+ type: string
+ source:
+ type: string
+ windowDays:
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ computedAt:
+ type: string
+ required:
+ - inputs
+ - source
+ - windowDays
+ - computedAt
+ additionalProperties: false
+ reason:
+ anyOf:
+ - type: string
+ - type: "null"
+ required:
+ - episodeId
+ - status
+ - value
+ - coverage
+ - confidence
+ - provenance
+ - reason
+ additionalProperties: false
+ description: Coverage-gated `Derived` wire shape for the per-episode correlation. `status:"insufficient"` carries
+ coverage + a reason and a null `value` — the surface renders 'still learning', never a fabricated number.
+ Server-authoritative; iOS pattern-matches `status`, never recomputes.
+ IllnessCorrelationValue:
+ type: object
+ properties:
+ episodeId:
+ type: string
+ preOnset:
+ type: array
+ items:
+ $ref: "#/components/schemas/IllnessVitalDeviation"
+ nadir:
+ type: array
+ items:
+ $ref: "#/components/schemas/IllnessVitalDeviation"
+ returns:
+ type: array
+ items:
+ $ref: "#/components/schemas/IllnessVitalReturn"
+ recoveryGapDays:
+ anyOf:
+ - type: number
+ - type: "null"
+ feltBetterDay:
+ anyOf:
+ - type: string
+ - type: "null"
+ redFlags:
+ type: array
+ items:
+ $ref: "#/components/schemas/IllnessRedFlag"
+ required:
+ - episodeId
+ - preOnset
+ - nadir
+ - returns
+ - recoveryGapDays
+ - feltBetterDay
+ - redFlags
+ additionalProperties: false
+ description: "The retrospective correlation findings for one episode: pre-onset anomaly scan, nadir, per-vital
+ physiological returns, the headline recovery-gap (median of per-vital gaps), and any red flags."
+ IllnessVitalDeviation:
+ type: object
+ properties:
+ type:
+ type: string
+ day:
+ type: string
+ value:
+ type: number
+ baselineCenter:
+ type: number
+ deviationSd:
+ type: number
+ direction:
+ type: string
+ enum:
+ - above
+ - below
+ adverse:
+ type: boolean
+ required:
+ - type
+ - day
+ - value
+ - baselineCenter
+ - deviationSd
+ - direction
+ - adverse
+ additionalProperties: false
+ description: "One vital's deviation finding on a day: signed deviation in robust-SD units from the user's OWN baseline
+ (median ± MAD), the direction, and whether the move is illness-adverse for that metric."
+ IllnessVitalReturn:
+ type: object
+ properties:
+ type:
+ type: string
+ returnedDay:
+ anyOf:
+ - type: string
+ - type: "null"
+ gapDays:
+ anyOf:
+ - type: number
+ - type: "null"
+ required:
+ - type
+ - returnedDay
+ - gapDays
+ additionalProperties: false
+ description: "A vital's physiological return: the first day it re-entered its band AND held, and the signed gap (days)
+ from the felt-better marker (positive = the body lagged the feeling)."
+ IllnessRedFlag:
+ type: object
+ properties:
+ type:
+ type: string
+ reason:
+ type: string
+ enum:
+ - sustained_low_spo2
+ - sustained_fever
+ worstValue:
+ type: number
+ days:
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ required:
+ - type
+ - reason
+ - worstValue
+ - days
+ additionalProperties: false
+ description: A retrospective red-flag escalation (sustained low SpO2 or sustained fever) against absolute clinical
+ floors. Copy must escalate ('seek care if this recurs'), never reassure.
+ IllnessInsightsEnvelope:
+ type: object
+ properties:
+ data:
+ $ref: "#/components/schemas/IllnessInsightsResponse"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ IllnessInsightsResponse:
+ type: object
+ properties:
+ windowDays:
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ episodeCount:
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ resolvedCount:
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ typicalRecoveryGapDays:
+ anyOf:
+ - type: number
+ - type: "null"
+ gapSampleSize:
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ byMonth:
+ type: object
+ propertyNames:
+ type: string
+ additionalProperties:
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ byType:
+ type: object
+ propertyNames:
+ type: string
+ additionalProperties:
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ required:
+ - windowDays
+ - episodeCount
+ - resolvedCount
+ - typicalRecoveryGapDays
+ - gapSampleSize
+ - byMonth
+ - byType
+ additionalProperties: false
+ description: "Cross-episode retrospective summary over the trailing window: episode + resolved counts, the typical
+ (median) recovery gap (null below the min-sample floor — withholds a thin claim), a recurrence-by-month tally,
+ and a per-type breakdown. Retrospective only."
MedicationScheduleOutput:
type: object
properties:
diff --git a/e2e/settings-mobile-consistency.spec.ts b/e2e/settings-mobile-consistency.spec.ts
index 9b40bec81..acc8ed7cc 100644
--- a/e2e/settings-mobile-consistency.spec.ts
+++ b/e2e/settings-mobile-consistency.spec.ts
@@ -89,10 +89,12 @@ test.describe("Settings mobile consistency (Pixel 5)", () => {
await page.goto("/settings/account", { waitUntil: "networkidle" });
await page.waitForLoadState("networkidle");
- // Both the password card and the tour card need the action button
- // to live within (or above the bottom of) the parent card. On
- // mobile the button stacks below the title — it's allowed to
- // extend the card's height, but not push past the right edge.
+ // The account page's action button(s) must live within (or above the
+ // bottom of) the parent card. Since v1.18.1 the "restart onboarding"
+ // tour button moved to Settings → Advanced, so account carries the
+ // change-password button; the tour button is matched here too in case
+ // it is present. On mobile the button stacks below the title — it's
+ // allowed to extend the card's height, but not push past the right edge.
const overflowCheck = await page.evaluate(() => {
const buttons = Array.from(document.querySelectorAll("button"));
const targets = buttons.filter((b) => {
@@ -118,8 +120,8 @@ test.describe("Settings mobile consistency (Pixel 5)", () => {
expect(
overflowCheck.length,
- "expected to find password + tour buttons",
- ).toBeGreaterThanOrEqual(2);
+ "expected to find the change-password action button on /settings/account",
+ ).toBeGreaterThanOrEqual(1);
for (const t of overflowCheck) {
expect(t.cardRight, `card for "${t.text}"`).not.toBeNull();
diff --git a/knip.json b/knip.json
index 367c93737..3b6200073 100644
--- a/knip.json
+++ b/knip.json
@@ -17,10 +17,9 @@
"src/app/**/robots.ts",
"src/app/**/sitemap.ts",
"src/app/**/default.tsx",
- "src/middleware.ts",
+ "src/proxy.ts",
"src/instrumentation.ts",
"src/lib/jobs/**/*.ts",
- "src/lib/health/**/*.ts",
"scripts/**/*.ts",
"scripts/**/*.mjs",
"e2e/**/*.spec.ts",
diff --git a/messages/de.json b/messages/de.json
index 35c813012..35477aa3c 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -123,37 +123,6 @@
"glucoseBelowTarget": "unter Ziel",
"glucoseAboveTarget": "über Ziel",
"practiceLabel": "Praxis",
- "dialog": {
- "title": "Arztbericht (PDF)",
- "description": "Lege den Auswertungszeitraum fest und (optional) den Praxisnamen — beide erscheinen auf dem Deckblatt.",
- "startLabel": "Von",
- "endLabel": "Bis",
- "practiceLabel": "Praxis / Klinik (optional)",
- "practicePlaceholder": "z. B. Dr. Müller — Hausarztpraxis",
- "preset90d": "Letzte 90 Tage",
- "preset180d": "Letzte 6 Monate",
- "preset365d": "Letzte 12 Monate",
- "submit": "PDF erstellen",
- "cancel": "Abbrechen",
- "errorEndBeforeStart": "Das Ende-Datum darf nicht vor dem Start liegen.",
- "errorRangeTooLong": "Der Auswertungszeitraum darf maximal 2 Jahre umfassen.",
- "errorInvalidDate": "Bitte wähle ein gültiges Start- und Ende-Datum."
- },
- "sections": {
- "title": "Inhalte auswählen",
- "bp": "Blutdruck",
- "weight": "Gewicht",
- "pulse": "Ruhepuls",
- "bmi": "Body-Mass-Index (BMI)",
- "mood": "Stimmung",
- "moodSensitive": "Sensibel — standardmäßig aus.",
- "compliance": "Therapietreue",
- "sleep": "Schlafdauer",
- "cycle": "Menstruationszyklus",
- "empty": "Für den gewählten Zeitraum liegen noch keine Daten vor. Der Bericht enthält dennoch das Deckblatt.",
- "unavailableHint": "Keine Daten in diesem Zeitraum",
- "labs": "Laborwerte"
- },
"insurer": "Krankenkasse",
"insuranceNumber": "Versichertennr.",
"summaryTitle": "Klinische Zusammenfassung",
@@ -204,7 +173,14 @@
"eA1c": "Geschätzter HbA1c",
"cv": "Variabilität (VK%)",
"spotCaveat": "Dies sind Schätzwerte aus Einzelmessungen, kein Profil eines kontinuierlichen Messgeräts — als Tendenz und nicht als klinisches Urteil zu lesen."
- }
+ },
+ "illnessTitle": "Erkrankungen",
+ "illnessOngoing": "Andauernd",
+ "illnessColCondition": "Erkrankung",
+ "illnessColType": "Art",
+ "illnessColLifecycle": "Verlauf",
+ "illnessColOnset": "Beginn",
+ "illnessColResolved": "Abgeklungen"
},
"errorBoundary": {
"title": "Da ist etwas schiefgelaufen",
@@ -266,7 +242,8 @@
"cycle": "Zyklus",
"labs": "Laborwerte",
"recovery": "Erholung",
- "vorsorge": "Vorsorge"
+ "vorsorge": "Vorsorge",
+ "illness": "Krankheit"
},
"auth": {
"login": "Anmelden",
@@ -1632,17 +1609,7 @@
"copyToken": "Token kopieren",
"mintedHint": "Token jetzt kopieren — es wird nur einmal angezeigt."
},
- "edit": {
- "planOption": "Plan bearbeiten",
- "advancedOption": "Erweiterte Einstellungen"
- },
"advanced": {
- "title": "Erweiterte Einstellungen",
- "group": {
- "data": "Daten",
- "lifecycle": "Lebenszyklus",
- "externalApi": "Externe API"
- },
"dataPortability": {
"import": {
"title": "Einnahmen importieren",
@@ -2188,23 +2155,6 @@
"confidenceLow": "Geringe Konfidenz",
"pendingLabel": "Trend-Kommentar wird generiert"
},
- "range": {
- "label": "Zeitraum",
- "noPriorData": "keine Daten im Vorzeitraum",
- "option": {
- "7d": "7T",
- "30d": "30T",
- "90d": "90T",
- "1y": "1J"
- },
- "priorLabel": {
- "7d": "ggü. vorherigen 7T",
- "30d": "ggü. vorherigen 30T",
- "90d": "ggü. vorherigen 90T",
- "1y": "ggü. Vorjahr"
- },
- "noChange": "keine Änderung"
- },
"correlationRow": {
"title": "Zusammenhänge",
"disclaimer": "Zusammenhänge sind beobachtend, nicht kausal — sprich mit deinem Arzt, wenn der Blutdruck hoch ist.",
@@ -2546,14 +2496,6 @@
"target": {
"adjustLink": "Zielbereich anpassen"
},
- "primary": {
- "avg30Label": "30-Tage-Durchschnitt",
- "inRangeLabel": "Im Zielbereich",
- "inRangeAria": "{pct}% der erfassten Tage im Zielbereich"
- },
- "lastMeasurement": {
- "label": "Letzte Messung"
- },
"explainer": {
"trigger": "Was ist {title}?",
"bloodPressureTitle": "Blutdruck",
@@ -2646,6 +2588,8 @@
"stairDescentSpeedBody": "Dies gibt an, wie schnell du eine Treppe hinabgehst. Zusammen mit dem Steig-Tempo zeigt es Beinkontrolle und Gleichgewicht — ein gleichmäßiges Tempo ist gut, ein sinkendes solltest du im Blick behalten.",
"breathingDisturbancesTitle": "Atemaussetzer im Schlaf",
"breathingDisturbancesBody": "Deine Apple Watch kann schätzen, wie oft deine Atmung im Schlaf unterbrochen war. Ein niedriger Index ist besser; ein von Apple als erhöht eingestufter Wert — vor allem über viele Nächte — sollte mit einer Ärztin oder einem Arzt besprochen werden.",
+ "stepsTitle": "Schritte",
+ "stepsBody": "Die Schrittzahl ist die Gesamtzahl der Schritte, die du täglich gehst. Sie ist das einfachste Maß für Alltagsbewegung — eine höhere Tageszahl spiegelt meist einen aktiveren Lebensstil wider, und eine stetige Gewohnheit zählt mehr als ein einzelner großer Tag.",
"readinessBody": "Die Tagesform ist ein einzelner Tageswert dafür, wie bereit dein Körper für den Tag ist — er verbindet Ruhepuls, Herzfrequenzvariabilität, Schlaf und die Beständigkeit deiner letzten Nächte. Ein höherer Wert heißt erholt und belastbar; ein niedrigerer ist ein sanfter Hinweis, einen Gang zurückzuschalten.",
"sleepScoreBody": "Dein Schlafscore fasst eine ganze Nacht in einer Zahl zusammen — aus Dauer, Effizienz, Regelmäßigkeit und der Verteilung von Leicht-, Tief- und REM-Schlaf. Achte eher auf das Muster über Wochen als auf einen perfekten Wert in einer einzelnen Nacht.",
"recoveryBody": "Die Erholung zeigt, wie gut sich dein Körper regeneriert hat — gestützt auf nächtliche Signale wie Herzfrequenzvariabilität und Ruhepuls. Ein hoher Wert ist grünes Licht, mehr zu geben; ein niedrigerer deutet an, dass dein System noch aufbaut.",
@@ -2750,6 +2694,11 @@
"critical": "Kritisch"
},
"assessmentTitle": "Einschätzung",
+ "restMode": {
+ "title": "Ruhemodus",
+ "body": "Eine Krankheits- oder Beschwerdephase ist aktiv, daher spiegelt dies eine Erholungsphase wider. Dein Score wird dafür nicht gesenkt.",
+ "bodySince": "Seit {since} ist eine Krankheits- oder Beschwerdephase aktiv, daher spiegelt dies eine Erholungsphase wider. Dein Score wird dafür nicht gesenkt."
+ },
"assessmentShowMore": "Mehr anzeigen",
"assessmentShowLess": "Weniger anzeigen",
"bloodPressureSectionTitle": "Blutdruck",
@@ -3461,7 +3410,8 @@
"watchVital": "Außerhalb des Bereichs: {vital}. Oft nur einmalig — behalte es in den nächsten Tagen im Blick.",
"firedHeadline": "{count} deiner Vitalwerte liegen heute außerhalb ihres persönlichen Bereichs.",
"factors": "Mögliche Faktoren — nie eine Ursache: ein hartes Training, schlechter Schlaf, Alkohol, Höhe, Stress oder Krankheit. Das ist ein Hinweis aus deinen eigenen Baselines, keine Diagnose.",
- "building": "Deine persönlichen Baselines entstehen — erfasse ein paar Tage mehr, dann erscheint dies."
+ "building": "Deine persönlichen Baselines entstehen — erfasse ein paar Tage mehr, dann erscheint dies.",
+ "illnessExplained": "Diese Abweichungen hängen wahrscheinlich mit der erfassten Erkrankung zusammen."
},
"trajectory": {
"cardTitle": "Kurzfrist-Projektion",
@@ -3633,10 +3583,14 @@
"title": "Herz & Energie pro Tag",
"subtitle": "Herz- und Energiewerte des ganzen Tages"
},
- "scoreLink": {
- "title": "Erholungs-Score",
- "subtitle": "Sieh dir an, wie sich der nächtliche Score zusammensetzt",
- "cta": "Ansehen"
+ "block": {
+ "ansChargeExplainer": "Wie aufgeladen dein autonomes Nervensystem in den Tag startet – ein Recharge-Wert aus deinen nächtlichen Herz- und Atemsignalen.",
+ "dayStrainExplainer": "Die kardiovaskuläre Tagesbelastung auf der Belastungsskala deines Geräts. Lies sie im Verhältnis zu deiner Erholung, nicht als Ziel für sich.",
+ "workoutStrainExplainer": "Wie fordernd ein einzelnes Training auf derselben Belastungsskala war – die kardiovaskuläre Last dieser Einheit.",
+ "cardioLoadExplainer": "Ein Trainingslast-Näherungswert, der die akut aufgebaute kardiovaskuläre Belastung abbildet. Vergleiche ihn mit deinen eigenen letzten Tagen.",
+ "averageHeartRateExplainer": "Deine durchschnittliche Herzfrequenz über den ganzen Tag bzw. Zyklus, wie dein Wearable sie meldet.",
+ "maxHeartRateExplainer": "Deine höchste Herzfrequenz über den ganzen Tag bzw. Zyklus, wie dein Wearable sie meldet.",
+ "energyExpenditureKjExplainer": "Die Energie, die dein Körper über den Tag verbraucht hat, in Kilojoule, wie dein Gerät sie nativ meldet."
}
},
"bodyTempDeviation": {
@@ -3871,18 +3825,7 @@
},
"integrations": {
"title": "Integrationen",
- "description": "Verbundene Dienste, Zustellkanäle und die Quellen-Priorität an einem Ort.",
- "tabs": {
- "connections": "Verbindungen",
- "channels": "Kanäle",
- "sources": "Quellen"
- },
- "connections": {
- "description": "Withings, WHOOP, Fitbit, Polar, Oura und Nightscout verbinden."
- },
- "channels": {
- "description": "Telegram, ntfy, Web-Push, Webhook und E-Mail – die Wege, über die Benachrichtigungen zugestellt werden."
- }
+ "description": "Verbundene Dienste, Zustellkanäle und die Quellen-Priorität an einem Ort."
},
"notifications": {
"title": "Benachrichtigungen",
@@ -3892,7 +3835,7 @@
"inboxLink": "Posteingang öffnen"
},
"layout": {
- "title": "Layout & Personalisierung",
+ "title": "Layout",
"description": "Lege fest, wie Dashboard und Insights aufgebaut und angeordnet sind.",
"backToHub": "Zurück zu Layout & Personalisierung",
"dashboard": {
@@ -3902,14 +3845,6 @@
"insights": {
"title": "Insights",
"description": "Reihenfolge der Übersicht und der Navigations-Pills."
- },
- "medications": {
- "title": "Medikamente",
- "description": "Listenansicht und Reihenfolge der Medikamente."
- },
- "mood": {
- "title": "Stimmungs-Tags",
- "description": "Gruppen, Tags und Reihenfolge der Tag-Auswahl."
}
},
"dashboard": {
@@ -3996,12 +3931,6 @@
"description": "Lade deine Daten als CSV oder vollständiges JSON-Backup herunter — oder importiere aus Apple Health, JSON oder CSV. Den Arztbericht findest du jetzt unter Gesundheitsakte.",
"otherOptionsHeading": "Weitere Export-Optionen",
"downloadFailed": "Der Export konnte nicht heruntergeladen werden. Bitte versuche es erneut.",
- "hero": {
- "eyebrow": "Arzttermin",
- "valueStatement": "Ein druckbarer PDF-Bericht für den Arzttermin — Vitalwerte, BMI, Blutdruck-Klassifikation, Therapietreue und (optional) Stimmungsverlauf, alles auf wenigen Seiten.",
- "cta": "Bericht konfigurieren & generieren",
- "formatHint": "PDF · druckfertig"
- },
"actions": {
"download": "Herunterladen"
},
@@ -4010,10 +3939,6 @@
"until": "Bis"
},
"cards": {
- "doctorReport": {
- "title": "Arztbericht",
- "description": "Druckbares PDF für den Arzttermin — Vitalwerte, BMI, BD-Klassifikation, Therapietreue, Stimmung."
- },
"measurementsCsv": {
"title": "Messwerte",
"description": "Gewicht, Blutdruck, Puls, Glukose und alle weiteren Messungen als CSV-Datei."
@@ -4146,6 +4071,10 @@
"coach": {
"title": "Coach",
"description": "Coach aktivieren, Ton und Umfang, gespeicherte Erinnerungen."
+ },
+ "channels": {
+ "title": "Kanäle",
+ "description": "Zustellung über Telegram, ntfy, Web-Push, Webhook und E-Mail."
}
},
"profile": "Profil",
@@ -4387,14 +4316,6 @@
"clearedToast": "{count} gespeicherte Angaben vergessen.",
"clearError": "Konnte die gespeicherten Angaben nicht löschen. Bitte erneut versuchen."
},
- "disableCoach": {
- "title": "Coach ausblenden",
- "description": "Versteckt den Coach-Button und das Coach-Panel auf allen Seiten.",
- "toggleAria": "Coach-Button und Coach-Panel ausblenden",
- "savedHidden": "Coach ist ausgeblendet.",
- "savedShown": "Coach ist sichtbar.",
- "saveError": "Speichern fehlgeschlagen. Bitte erneut versuchen."
- },
"aboutMe": {
"title": "Über mich",
"description": "Persönlicher Kontext, den Coach und Tagesbriefing zusätzlich zu deinen Daten lesen.",
@@ -4857,7 +4778,17 @@
"rate_limited": "Zu viele Verbindungsversuche. Warte einen Moment und versuche es erneut.",
"generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut."
},
- "integrationSetupGuide": "Einrichtungsanleitung"
+ "integrationSetupGuide": "Einrichtungsanleitung",
+ "coach": {
+ "activate": {
+ "title": "Coach aktivieren",
+ "description": "Zeigt den Coach-Button und das Coach-Panel. Standardmäßig an — ausschalten, um den Coach überall auszublenden.",
+ "toggleAria": "Coach-Button und Coach-Panel aktivieren",
+ "savedOn": "Coach ist aktiviert.",
+ "savedOff": "Coach ist ausgeblendet.",
+ "saveError": "Speichern fehlgeschlagen. Bitte erneut versuchen."
+ }
+ }
},
"admin": {
"title": "Administration",
@@ -5145,6 +5076,7 @@
"deleteAllConfirm": "Alle Daten wirklich löschen?",
"deleteAllConfirmDescription": "Dieser Vorgang löscht alle Messwerte, Medikamente, Einnahmen, API-Tokens, Withings-Verbindungen, offene Authentifizierungs-Challenges, Benachrichtigungskanäle (Telegram, ntfy, Web Push), Push-Abonnements und in der Warteschlange befindliche Telegram-Nachrichtenlöschungen aller Benutzer unwiderruflich. Benutzerkonten, das Audit-Log und eingereichtes Feedback bleiben erhalten, damit der Löschvorgang nachvollziehbar bleibt und Bug-Reports weiter triagierbar sind.",
"deleteButton": "Alle Daten löschen",
+ "deleteAllPromptLabel": "Tippe {token} zum Fortfahren",
"finalDelete": "Endgültig löschen",
"deletedResult": "Gelöscht: {measurements} Messwerte, {medications} Medikamente, {intakeEvents} Einnahmen, {apiTokens} API-Tokens, {withingsConnections} Withings-Verbindungen, {authChallenges} Auth-Challenges, {notificationChannels} Benachrichtigungskanäle, {pushSubscriptions} Push-Abonnements, {telegramScheduledDeletions} eingeplante Telegram-Löschungen. Feedback bleibt erhalten.",
"authRegister": "Registrierung",
@@ -5273,17 +5205,9 @@
"title": "Integrationen",
"subtitle": "Umami, GlitchTip, Web Push und Bug-Reports konfigurieren."
},
- "ai-quality": {
- "title": "Auswertungs-Qualität",
- "subtitle": "Hilfreichkeit nach Schweregrad und Provider — Nutzer-Feedback der letzten 30 Tage."
- },
- "assistant": {
- "title": "Assistenz-Oberflächen",
- "subtitle": "Jede modellgestützte Oberfläche separat ein- oder ausschalten, ohne die Provider-Konfiguration zu entfernen."
- },
- "coach-feedback": {
- "title": "Coach-Feedback",
- "subtitle": "Hilfreich-Quote pro Coach-Prompt-Version, Tonfall und Ausführlichkeit der letzten 30 Tage."
+ "coach": {
+ "title": "Coach",
+ "subtitle": "Assistenz-Oberflächen, Server-Schlüssel sowie die Feedback-Qualitäts-Tabellen für Coach und Auswertungen an einem Ort."
},
"feedback": {
"title": "Feedback",
@@ -5875,11 +5799,6 @@
"sixDaysAgo": "vor 6 Tagen"
}
},
- "relativeDay": {
- "today": "heute",
- "yesterday": "gestern",
- "daysAgo": "vor {count} Tagen"
- },
"edit": {
"openLabel": "Zielbereich für {metric} bearbeiten",
"title": "Zielbereich bearbeiten — {metric}",
@@ -6344,7 +6263,9 @@
"delete": "Löschen",
"temperatureSection": "Temperatur & Eisprung",
"intimacySection": "Intimität & Verhütung",
- "testsSection": "Tests"
+ "testsSection": "Tests",
+ "phaseDayHeader": "Tag {day} · {phase}",
+ "bbtHint": "Miss sie morgens als Erstes, noch vor dem Aufstehen."
},
"settings": {
"title": "Zyklus-Einstellungen",
@@ -6420,6 +6341,24 @@
"ovulationEstimate": "Gesch. Eisprung",
"tooltipTemp": "{temp} °C",
"ovulationConfirmed": "Bestätigter Eisprung"
+ },
+ "fieldInfo": {
+ "bbt": "Deine Ruhetemperatur, morgens als Erstes vor dem Aufstehen gemessen. Nach dem Eisprung steigt sie leicht an (etwa 0,2–0,3 °C).",
+ "bbtLabel": "Was ist die Basaltemperatur?",
+ "mucus": "Der Ausfluss verändert sich im Zyklus – von trocken oder klebrig zu cremig, dann wässrig und spinnbar rund um die fruchtbarsten Tage.",
+ "mucusLabel": "Was ist Zervixschleim?",
+ "ovulationTest": "Ein Urintest für zu Hause, der den LH-Anstieg erkennt – den Hormonschub etwa 24–36 h vor dem Eisprung. Ein positives Ergebnis markiert die fruchtbarste Phase.",
+ "ovulationTestLabel": "Was ist ein Ovulationstest?",
+ "spotting": "Leichte Blutung außerhalb der Periode.",
+ "spottingLabel": "Was ist Schmierblutung zwischen den Perioden?",
+ "pregnancyTest": "Ergebnis eines Schwangerschaftstests (hCG) für zu Hause.",
+ "pregnancyTestLabel": "Was ist ein Schwangerschaftstest?",
+ "progesteroneTest": "Ein Test, der bestätigt, dass der Eisprung stattgefunden hat – das Progesteron steigt danach an.",
+ "progesteroneTestLabel": "Was ist ein Progesterontest?",
+ "intercourse": "Ob an diesem Tag Geschlechtsverkehr stattgefunden hat.",
+ "intercourseLabel": "Was erfasst Geschlechtsverkehr?",
+ "protected": "Ob beim Verkehr verhütet wurde.",
+ "protectedLabel": "Was bedeutet geschützt?"
}
},
"cycleReminders": {
@@ -6478,7 +6417,19 @@
"BLOOD_GLUCOSE": "Blutzucker",
"OXYGEN_SATURATION": "Sauerstoffsättigung",
"BODY_FAT": "Körperfett",
- "BODY_TEMPERATURE": "Körpertemperatur"
+ "BODY_TEMPERATURE": "Körpertemperatur",
+ "FAT_MASS": "Fettmasse",
+ "FAT_FREE_MASS": "Fettfreie Masse",
+ "MUSCLE_MASS": "Muskelmasse",
+ "LEAN_BODY_MASS": "Magermasse",
+ "BONE_MASS": "Knochenmasse",
+ "TOTAL_BODY_WATER": "Körperwasser",
+ "VISCERAL_FAT": "Viszeralfett",
+ "BODY_MASS_INDEX": "Body-Mass-Index (BMI)"
+ },
+ "typeGroups": {
+ "vitals": "Vitalwerte",
+ "bodyComposition": "Körperzusammensetzung"
},
"empty": {
"title": "Noch keine Erinnerungen",
@@ -6495,22 +6446,28 @@
"notifyHour": "Erinnern um",
"location": "Wo (optional)",
"locationPlaceholder": "z. B. Hausarzt, Labor",
- "save": "Speichern"
+ "save": "Speichern",
+ "createTitle": "Neue Erinnerung",
+ "editTitle": "Erinnerung bearbeiten"
},
"pushTitle": "Vorsorge-Erinnerung",
"pushBody": "Fällig: {label}",
- "pushLocation": "(bei {location})"
+ "pushLocation": "(bei {location})",
+ "edit": "Bearbeiten",
+ "originCoach": "Coach",
+ "enabledToggleAria": "Diese Erinnerung aktivieren oder deaktivieren",
+ "until": "Bis {date}"
},
"labs": {
"title": "Laborwerte",
"subtitle": "Blutwerte und Biomarker",
- "addResult": "Hinzufügen",
+ "addResult": "Wert hinzufügen",
"addDescription": "Erfasse einen Biomarker aus deinem Laborbefund.",
"addFirst": "Ersten Wert erfassen",
"emptyTitle": "Noch keine Laborwerte",
"emptyDescription": "Erfasse Blutwerte wie HbA1c, LDL oder Ferritin, um sie über die Zeit zu verfolgen und mit deiner Ärztin oder deinem Arzt zu teilen.",
"referenceLabel": "Referenz",
- "readingsCount": "{count} Werte",
+ "readingsCount": "{count} Messungen",
"deleteLatest": "Letzten Wert löschen",
"deleteConfirmTitle": "Laborwert löschen?",
"deleteConfirmDescription": "Dieser Laborwert wird dauerhaft gelöscht.",
@@ -6539,12 +6496,130 @@
"referenceHint": "Übernimm den Referenzbereich aus deinem Laborbefund. Beide Grenzen sind optional.",
"note": "Notiz",
"notePlaceholder": "Optionaler Kontext zu diesem Wert",
- "requiredError": "Biomarker, Wert und Einheit sind erforderlich.",
+ "requiredError": "Bitte einen Wert eingeben.",
"rangeOrderError": "Die untere Referenz darf nicht über der oberen liegen.",
"save": "Speichern",
"savedToast": "Wert gespeichert",
- "saveError": "Wert konnte nicht gespeichert werden"
- }
+ "saveError": "Wert konnte nicht gespeichert werden",
+ "biomarker": "Biomarker",
+ "biomarkerPlaceholder": "Biomarker auswählen",
+ "defineNew": "+ Neuen Biomarker anlegen",
+ "noBiomarkersHint": "Lege zuerst einen Biomarker an, um einen Wert zu erfassen.",
+ "pickBiomarkerError": "Bitte zuerst einen Biomarker auswählen."
+ },
+ "backToLabs": "Zurück zu den Laborwerten",
+ "hasNote": "Notiz",
+ "editTitle": "Wert bearbeiten",
+ "editDescription": "Wert, Datum oder Notiz dieser Messung korrigieren.",
+ "editedToast": "Wert aktualisiert",
+ "editError": "Wert konnte nicht aktualisiert werden",
+ "editTimestampError": "Bitte ein gültiges Datum eingeben.",
+ "editReading": "Wert bearbeiten",
+ "deleteReading": "Wert löschen",
+ "restoredToast": "Wert wiederhergestellt",
+ "restoreError": "Wert konnte nicht wiederhergestellt werden",
+ "detail": {
+ "history": "Verlauf",
+ "emptyTitle": "Noch keine Werte",
+ "emptyDescription": "Füge einen Wert hinzu, um diesen Biomarker über die Zeit zu verfolgen."
+ },
+ "chart": {
+ "empty": "Keine Werte in diesem Zeitraum.",
+ "valueLabel": "Wert",
+ "range": {
+ "30": "30 T",
+ "90": "90 T",
+ "365": "1 J",
+ "all": "Alle"
+ }
+ },
+ "biomarker": {
+ "manage": "Biomarker",
+ "manageTitle": "Biomarker",
+ "manageSheetDescription": "Lege die Marker an, die du verfolgst. Jeder wird einmal mit Einheit und Referenzbereich eingerichtet.",
+ "managerDescription": "Marker einmal definieren — Einheit und Bereich gelten für jede Messung.",
+ "define": "Anlegen",
+ "defineFirst": "Ersten Biomarker anlegen",
+ "defineTitle": "Biomarker anlegen",
+ "defineDescription": "Name, Einheit und Referenzbereich einmalig festlegen. Beim Erfassen wählst du nur den Marker.",
+ "editTitle": "Biomarker bearbeiten",
+ "edit": "Bearbeiten",
+ "delete": "Löschen",
+ "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.",
+ "emptyTitle": "Noch keine Biomarker",
+ "emptyDescription": "Lege Marker wie LDL oder HbA1c an, um Werte zu erfassen.",
+ "loadError": "Biomarker konnten nicht geladen werden",
+ "noRange": "Kein Referenzbereich",
+ "form": {
+ "seedLabel": "Von einem gängigen Marker starten",
+ "seedPlaceholder": "Marker wählen (optional)",
+ "seedHint": "Eine Auswahl füllt die Felder unten — passe den Bereich an dein Labor an.",
+ "name": "Name",
+ "namePlaceholder": "z. B. LDL-Cholesterin",
+ "unit": "Einheit",
+ "unitPlaceholder": "z. B. mg/dl",
+ "panel": "Panel",
+ "panelPlaceholder": "z. B. Lipide",
+ "lowerBound": "Referenz unten",
+ "upperBound": "Referenz oben",
+ "lowerPlaceholder": "Untere Grenze",
+ "upperPlaceholder": "Obere Grenze",
+ "context": "Kontext",
+ "contextPlaceholder": "Optionale Notiz zu diesem Marker",
+ "rangeHint": "Beide Grenzen sind optional — eine leer lassen für einen offenen Bereich (z. B. LDL < 116).",
+ "requiredError": "Name und Einheit sind erforderlich.",
+ "rangeOrderError": "Die untere Grenze darf die obere nicht überschreiten.",
+ "save": "Biomarker speichern",
+ "savedToast": "Biomarker gespeichert",
+ "updatedToast": "Biomarker aktualisiert",
+ "saveError": "Biomarker konnte nicht gespeichert werden"
+ }
+ },
+ "catalog": {
+ "total-cholesterol": "Gesamtcholesterin",
+ "ldl": "LDL-Cholesterin",
+ "hdl": "HDL-Cholesterin",
+ "triglycerides": "Triglyceride",
+ "fasting-glucose": "Nüchternglukose",
+ "hba1c": "HbA1c",
+ "tsh": "TSH",
+ "ft3": "Freies T3",
+ "ft4": "Freies T4",
+ "ferritin": "Ferritin",
+ "transferrin-saturation": "Transferrinsättigung",
+ "vitamin-d": "Vitamin D (25-OH)",
+ "vitamin-b12": "Vitamin B12",
+ "folate": "Folsäure",
+ "hs-crp": "hs-CRP",
+ "creatinine": "Kreatinin",
+ "egfr": "eGFR",
+ "alt": "ALT (GPT)",
+ "ast": "AST (GOT)",
+ "ggt": "GGT",
+ "sodium": "Natrium",
+ "potassium": "Kalium",
+ "hemoglobin": "Hämoglobin",
+ "hematocrit": "Hämatokrit",
+ "wbc": "Leukozyten",
+ "platelets": "Thrombozyten",
+ "panel": {
+ "lipids": "Lipide",
+ "glucose": "Glukosestoffwechsel",
+ "thyroid": "Schilddrüse",
+ "iron": "Eisen",
+ "vitamins": "Vitamine",
+ "inflammation": "Entzündung",
+ "renal": "Niere",
+ "liver": "Leber",
+ "electrolytes": "Elektrolyte",
+ "bloodCount": "Blutbild"
+ }
+ },
+ "noteLoadFailed": "Notiz konnte nicht geladen werden; sie bleibt beim Speichern unverändert.",
+ "showingLatestOf": "Die neuesten {shown} von {total} Messwerten werden angezeigt"
},
"modules": {
"cycle": {
@@ -6584,7 +6659,7 @@
"description": "KI-Chat mit Bezug zu deinen Daten."
},
"insights": {
- "label": "Auswertungen",
+ "label": "Insights",
"description": "KI-Analysen, Briefing und Korrelationen."
},
"doctorReport": {
@@ -6606,6 +6681,165 @@
"medications": {
"label": "Medikamente",
"description": "Pläne, Einnahmen und Compliance."
+ },
+ "illness": {
+ "label": "Krankheitstagebuch",
+ "description": "Krankheits- und Beschwerde-Episoden mit täglichem Symptomverlauf festhalten."
+ }
+ },
+ "illness": {
+ "title": "Krankheitstagebuch",
+ "subtitle": "Ein rückblickendes Protokoll deiner Krankheits- und Beschwerde-Episoden.",
+ "disclaimer": "Ein persönliches Tagebuch — kein Medizinprodukt. Es stellt keine Diagnose und keine Prognose.",
+ "newEpisode": "Neue Episode",
+ "logDay": "Tag erfassen",
+ "markRecovered": "Als genesen markieren",
+ "onsetOn": "Begonnen am {date}",
+ "recoveredOn": "genesen {date}",
+ "status": {
+ "active": "Aktiv",
+ "ongoing": "Andauernd",
+ "recovered": "Genesen"
+ },
+ "empty": {
+ "title": "Noch keine Episoden",
+ "body": "Beginne eine Episode, um einen täglichen Symptomverlauf zu führen und nachzusehen, wie sie verlief."
+ },
+ "type": {
+ "INFECTION": "Infektion",
+ "ALLERGY": "Allergie",
+ "INJURY": "Verletzung",
+ "MENTAL_HEALTH": "Psychische Gesundheit",
+ "AUTOIMMUNE": "Autoimmun",
+ "CHRONIC": "Chronisch",
+ "OTHER": "Sonstiges"
+ },
+ "lifecycle": {
+ "ACUTE": "Akut",
+ "CHRONIC_ONGOING": "Chronisch / andauernd",
+ "RECURRING": "Wiederkehrend",
+ "FLARE": "Schub"
+ },
+ "new": {
+ "title": "Neue Episode",
+ "description": "Erfasse eine Krankheit oder Beschwerde. Tägliche Symptome kannst du danach festhalten.",
+ "label": "Bezeichnung",
+ "labelPlaceholder": "z. B. Erkältung",
+ "type": "Art",
+ "lifecycle": "Verlauf",
+ "onset": "Begonnen am",
+ "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"
+ },
+ "sheet": {
+ "title": "{date} erfassen",
+ "description": "Halte die heutigen Symptome, deine Belastbarkeit und ggf. Fieber fest.",
+ "symptoms": "Symptome",
+ "functionalImpact": "Belastbarkeit",
+ "fever": "Temperatur",
+ "note": "Notiz",
+ "noteSet": "Gesetzt",
+ "notePlaceholder": "Wie sich der Tag anfühlte (verschlüsselt).",
+ "severityFor": "Stärke für {symptom}",
+ "saveError": "Der Tag konnte nicht gespeichert werden. Bitte erneut versuchen.",
+ "day": "Day"
+ },
+ "impact": {
+ "0": "Voll belastbar",
+ "1": "Leicht eingeschränkt",
+ "2": "Überwiegend ruhend",
+ "3": "Bettlägerig"
+ },
+ "symptom": {
+ "runnyNose": "Laufende Nase",
+ "stuffyNose": "Verstopfte Nase",
+ "sneezing": "Niesen",
+ "soreThroat": "Halsschmerzen",
+ "cough": "Husten",
+ "headache": "Kopfschmerzen",
+ "bodyAches": "Gliederschmerzen",
+ "fatigue": "Erschöpfung"
+ },
+ "correlation": {
+ "title": "Wie es verlief",
+ "learning": "Noch am Lernen — erfasse ein paar weitere Tage, um hier Muster zu sehen.",
+ "preOnsetTitle": "Wie es sich ankündigte",
+ "preOnsetBody": "Werte, die vor dem Beginn von deiner Baseline abwichen.",
+ "nadirTitle": "Was sich änderte",
+ "nadirBody": "Wie weit jeder erfasste Wert von deinem typischen Bereich abwich.",
+ "recoveryGapTitle": "Erholungslücke",
+ "recoveryGapLag": "Deine Werte normalisierten sich {days} Tage, nachdem es dir besser ging.",
+ "recoveryGapLead": "Deine Werte normalisierten sich {days} Tage, bevor du Genesung vermerktest.",
+ "recoveryGapSame": "Deine Werte normalisierten sich am selben Tag, an dem es dir besser ging.",
+ "redFlagTitle": "Genauer ansehen",
+ "redFlagSpo2": "Deine Sauerstoffsättigung blieb mehrere Tage niedrig. Wenn das erneut auftritt, suche ärztliche Hilfe.",
+ "redFlagFever": "Du hattest anhaltend Fieber. Wenn das erneut auftritt, suche ärztliche Hilfe.",
+ "direction": {
+ "above": "über deinem Üblichen",
+ "below": "unter deinem Üblichen"
+ }
+ },
+ "insights": {
+ "title": "Dein Krankheitsverlauf",
+ "countOne": "Du hast in diesem Zeitraum 1 Episode erfasst.",
+ "countOther": "Du hast in diesem Zeitraum {count} Episoden erfasst.",
+ "typicalGap": "Typische Erholungslücke: {days} Tage zwischen Besserung und normalisierten Werten.",
+ "typicalGapLearning": "Noch ein paar abgeschlossene Episoden und hier erscheint eine typische Erholungslücke.",
+ "byMonth": "Wann du Episoden erfasst",
+ "none": "In diesem Zeitraum keine Episoden erfasst."
+ },
+ "loadError": "Could not load this episode.",
+ "backToList": "Back to journal",
+ "detail": {
+ "note": "Note"
+ },
+ "edit": {
+ "title": "Edit episode",
+ "description": "Update this episode's details."
+ },
+ "menu": {
+ "label": "Episode actions"
+ },
+ "deleteConfirm": {
+ "title": "Delete this episode?",
+ "body": "“{label}” and all its day-logs will be removed. This cannot be undone."
+ },
+ "vital": {
+ "RESTING_HEART_RATE": "Resting heart rate",
+ "HEART_RATE_VARIABILITY": "Heart-rate variability",
+ "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",
+ "PULSE": "Pulse",
+ "WEIGHT": "Weight"
+ }
+ },
+ "coach": {
+ "reminderSuggestion": {
+ "title": "Erinnerung einrichten?",
+ "accept": "Erinnerung setzen",
+ "dismiss": "Jetzt nicht",
+ "stop": "Ich messe genug",
+ "accepted": "Erinnerung gesetzt",
+ "dismissed": "Okay, das schlage ich nicht noch einmal vor",
+ "stopped": "Verstanden – ich schlage keine Erinnerungen mehr vor",
+ "failed": "Hat nicht geklappt – bitte erneut versuchen",
+ "cadence": {
+ "weightDaily": "Wieg dich täglich, ungefähr zur selben Zeit",
+ "bp722": "Miss deinen Blutdruck eine Woche lang zweimal täglich",
+ "glucoseStructured": "Miss deine Glukose nach einem strukturierten Tagesplan"
+ }
+ },
+ "settings": {
+ "reminderSuggestionsLabel": "Kadenz-Vorschläge",
+ "reminderSuggestionsDescription": "Lass den Coach gelegentlich eine evidenzbasierte Erinnerung anbieten, eine Metrik regelmäßiger zu messen."
}
}
}
diff --git a/messages/en.json b/messages/en.json
index ce511ebb8..b651f7a98 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -123,37 +123,6 @@
"glucoseBelowTarget": "below target",
"glucoseAboveTarget": "above target",
"practiceLabel": "Practice",
- "dialog": {
- "title": "Doctor report (PDF)",
- "description": "Pick the reporting period and (optionally) the practice name; both are printed on the cover.",
- "startLabel": "Start date",
- "endLabel": "End date",
- "practiceLabel": "Practice / clinic name (optional)",
- "practicePlaceholder": "e.g. Dr. Müller — Family practice",
- "preset90d": "Last 90 days",
- "preset180d": "Last 6 months",
- "preset365d": "Last 12 months",
- "submit": "Generate PDF",
- "cancel": "Cancel",
- "errorEndBeforeStart": "End date must be on or after the start date.",
- "errorRangeTooLong": "The reporting period cannot exceed 2 years.",
- "errorInvalidDate": "Please select a valid start and end date."
- },
- "sections": {
- "title": "Choose sections",
- "bp": "Blood pressure",
- "weight": "Weight",
- "pulse": "Resting pulse",
- "bmi": "Body Mass Index (BMI)",
- "mood": "Mood",
- "moodSensitive": "Sensitive — off by default.",
- "compliance": "Medication adherence",
- "sleep": "Sleep duration",
- "cycle": "Menstrual cycle",
- "empty": "No data was recorded in this date range yet. The report will still include the cover page.",
- "unavailableHint": "No data in this period",
- "labs": "Lab results"
- },
"insurer": "Health insurer",
"insuranceNumber": "Insurance no.",
"summaryTitle": "Clinical summary",
@@ -204,7 +173,14 @@
"eA1c": "Estimated A1C",
"cv": "Variability (CV%)",
"spotCaveat": "These are spot-reading estimates from individual measurements, not a continuous-monitor profile — read them as a direction of travel rather than a clinical verdict."
- }
+ },
+ "illnessTitle": "Conditions & illnesses",
+ "illnessOngoing": "Ongoing",
+ "illnessColCondition": "Condition",
+ "illnessColType": "Type",
+ "illnessColLifecycle": "Course",
+ "illnessColOnset": "Onset",
+ "illnessColResolved": "Resolved"
},
"errorBoundary": {
"title": "Something went wrong",
@@ -266,7 +242,8 @@
"cycle": "Cycle",
"labs": "Labs",
"recovery": "Recovery",
- "vorsorge": "Preventive Care"
+ "vorsorge": "Preventive Care",
+ "illness": "Illness"
},
"auth": {
"login": "Sign in",
@@ -1632,17 +1609,7 @@
"copyToken": "Copy token",
"mintedHint": "Copy this token now — it is shown only once."
},
- "edit": {
- "planOption": "Edit plan",
- "advancedOption": "Advanced settings"
- },
"advanced": {
- "title": "Advanced settings",
- "group": {
- "data": "Data",
- "lifecycle": "Lifecycle",
- "externalApi": "External API"
- },
"dataPortability": {
"import": {
"title": "Import intakes",
@@ -2188,23 +2155,6 @@
"confidenceLow": "Low confidence",
"pendingLabel": "Generating trend commentary"
},
- "range": {
- "label": "Time range",
- "noPriorData": "no prior-period data",
- "option": {
- "7d": "7d",
- "30d": "30d",
- "90d": "90d",
- "1y": "1y"
- },
- "priorLabel": {
- "7d": "vs prior 7d",
- "30d": "vs prior 30d",
- "90d": "vs prior 90d",
- "1y": "vs prior year"
- },
- "noChange": "no change"
- },
"correlationRow": {
"title": "Relationships",
"disclaimer": "Relationships are observational, not causal — talk to your doctor if BP runs high.",
@@ -2546,14 +2496,6 @@
"target": {
"adjustLink": "Adjust target range"
},
- "primary": {
- "avg30Label": "30-day average",
- "inRangeLabel": "In range",
- "inRangeAria": "{pct}% of logged days within target"
- },
- "lastMeasurement": {
- "label": "Last measurement"
- },
"explainer": {
"trigger": "What is {title}?",
"bloodPressureTitle": "Blood pressure",
@@ -2646,6 +2588,8 @@
"stairDescentSpeedBody": "This is how fast you go down a flight of stairs. Together with ascent speed it tracks leg control and balance — a steady pace is a good sign, and a falling one is worth watching.",
"breathingDisturbancesTitle": "Sleep breathing disturbances",
"breathingDisturbancesBody": "Your Apple Watch can estimate how often your breathing was interrupted during sleep. A lower index is better; a level Apple flags as elevated, especially over many nights, is worth raising with a clinician.",
+ "stepsTitle": "Steps",
+ "stepsBody": "Step count is the total number of steps you take each day. It is the simplest measure of everyday movement — a higher daily count generally reflects a more active lifestyle, and a steady habit matters more than any single big day.",
"readinessBody": "Readiness is a single daily read on how prepared your body is to take on the day, blending resting heart rate, heart-rate variability, sleep, and how steady your recent nights have been. A higher score means recovered and resourced; a lower one is a gentle cue to ease off.",
"sleepScoreBody": "Your sleep score distils a whole night into one number — duration, efficiency, regularity, and the balance of light, deep, and REM. Watch the pattern across weeks rather than chasing a perfect score on any single night.",
"recoveryBody": "Recovery reflects how well your body has bounced back, drawing on overnight signals like heart-rate variability and resting heart rate. A strong score is a green light to push; a softer one suggests your system is still rebuilding.",
@@ -2750,6 +2694,11 @@
"critical": "Critical"
},
"assessmentTitle": "Assessment",
+ "restMode": {
+ "title": "Rest Mode",
+ "body": "An illness or condition episode is active, so this reflects a recovery window. Your score isn't lowered for it.",
+ "bodySince": "An illness or condition episode has been active since {since}, so this reflects a recovery window. Your score isn't lowered for it."
+ },
"assessmentShowMore": "Show more",
"assessmentShowLess": "Show less",
"bloodPressureSectionTitle": "Blood Pressure",
@@ -3461,7 +3410,8 @@
"watchVital": "Outside range: {vital}. Often just a one-off — worth a glance over the next few days.",
"firedHeadline": "{count} of your vitals are outside their personal range today.",
"factors": "Possible factors — never a cause: a hard workout, poor sleep, alcohol, altitude, stress, or illness. This is awareness from your own baselines, not a diagnosis.",
- "building": "Building your personal baselines — track a few more days and this will appear."
+ "building": "Building your personal baselines — track a few more days and this will appear.",
+ "illnessExplained": "These shifts likely reflect the condition you logged."
},
"trajectory": {
"cardTitle": "Short-horizon projection",
@@ -3633,10 +3583,14 @@
"title": "Daily heart & energy",
"subtitle": "Whole-day cardiac and energy totals"
},
- "scoreLink": {
- "title": "Recovery score",
- "subtitle": "See how the nightly score breaks down",
- "cta": "View"
+ "block": {
+ "ansChargeExplainer": "How charged your autonomic nervous system is for the day ahead — a recharge reading drawn from your overnight heart and breathing signals.",
+ "dayStrainExplainer": "The day's whole-body cardiovascular load on your device's strain scale. Read it against your recovery, not as a goal on its own.",
+ "workoutStrainExplainer": "How demanding a single workout was on the same strain scale — the cardiovascular cost of that session.",
+ "cardioLoadExplainer": "A training-load proxy that tracks the acute cardiovascular load you have built up. Compare it with your own recent days.",
+ "averageHeartRateExplainer": "Your average heart rate across the whole day or cycle, as reported by your wearable.",
+ "maxHeartRateExplainer": "Your peak heart rate across the whole day or cycle, as reported by your wearable.",
+ "energyExpenditureKjExplainer": "The energy your body expended over the day, in kilojoules, as your device reports it natively."
}
},
"bodyTempDeviation": {
@@ -3871,18 +3825,7 @@
},
"integrations": {
"title": "Integrations",
- "description": "Connected services, delivery channels, and source priority in one place.",
- "tabs": {
- "connections": "Connections",
- "channels": "Channels",
- "sources": "Sources"
- },
- "connections": {
- "description": "Connect Withings, WHOOP, Fitbit, Polar, Oura, and Nightscout."
- },
- "channels": {
- "description": "Telegram, ntfy, Web Push, Webhook, and Email — the routes notifications are delivered through."
- }
+ "description": "Connected services, delivery channels, and source priority in one place."
},
"notifications": {
"title": "Notifications",
@@ -3892,7 +3835,7 @@
"inboxLink": "Open the inbox"
},
"layout": {
- "title": "Layout & Personalization",
+ "title": "Layout",
"description": "Arrange how the dashboard and insights are laid out.",
"backToHub": "Back to Layout & Personalization",
"dashboard": {
@@ -3902,14 +3845,6 @@
"insights": {
"title": "Insights",
"description": "Overview sections and navigation-pill order."
- },
- "medications": {
- "title": "Medications",
- "description": "List view and medication order."
- },
- "mood": {
- "title": "Mood tags",
- "description": "Groups, tags, and order of the mood picker."
}
},
"dashboard": {
@@ -3996,12 +3931,6 @@
"description": "Download your data as CSV or a full JSON backup — or import from Apple Health, JSON, or CSV. The doctor report now lives under Health Record.",
"otherOptionsHeading": "Other export options",
"downloadFailed": "Couldn't download the export. Please try again.",
- "hero": {
- "eyebrow": "Doctor visit",
- "valueStatement": "A printable PDF report for your doctor's appointment — vitals, BMI, blood-pressure classification, medication adherence and (optionally) mood, all on a few pages.",
- "cta": "Configure & generate report",
- "formatHint": "PDF · print-ready"
- },
"actions": {
"download": "Download"
},
@@ -4010,10 +3939,6 @@
"until": "Until"
},
"cards": {
- "doctorReport": {
- "title": "Doctor Report",
- "description": "Printable PDF for the doctor's appointment — vitals, BMI, BP classification, medication adherence, mood."
- },
"measurementsCsv": {
"title": "Measurements",
"description": "Weight, blood pressure, pulse, glucose, and every other measurement as comma-separated values."
@@ -4146,6 +4071,10 @@
"coach": {
"title": "Coach",
"description": "Enable the Coach, tone and scope, stored memories."
+ },
+ "channels": {
+ "title": "Channels",
+ "description": "Telegram, ntfy, Web Push, Webhook, and Email delivery."
}
},
"profile": "Profile",
@@ -4387,14 +4316,6 @@
"clearedToast": "Forgot {count} stored facts.",
"clearError": "Could not clear the stored facts. Please try again."
},
- "disableCoach": {
- "title": "Hide Coach",
- "description": "Hides the Coach button and drawer everywhere.",
- "toggleAria": "Hide the Coach button and drawer",
- "savedHidden": "Coach hidden.",
- "savedShown": "Coach visible.",
- "saveError": "Could not save the change. Please try again."
- },
"aboutMe": {
"title": "About me",
"description": "Personal context the Coach and the daily briefing read alongside your data.",
@@ -4857,7 +4778,17 @@
"rate_limited": "Too many connection attempts. Wait a moment and try again.",
"generic": "Unknown error while connecting. Please start the connection again."
},
- "integrationSetupGuide": "Setup guide"
+ "integrationSetupGuide": "Setup guide",
+ "coach": {
+ "activate": {
+ "title": "Activate Coach",
+ "description": "Show the Coach button and drawer. On by default — turn it off to hide the Coach everywhere.",
+ "toggleAria": "Activate the Coach button and drawer",
+ "savedOn": "Coach activated.",
+ "savedOff": "Coach hidden.",
+ "saveError": "Could not save the change. Please try again."
+ }
+ }
},
"admin": {
"title": "Administration",
@@ -5145,6 +5076,7 @@
"deleteAllConfirm": "Really delete all data?",
"deleteAllConfirmDescription": "This will permanently delete all measurements, medications, intake events, API tokens, Withings connections, open authentication challenges, notification channels (Telegram, ntfy, Web Push), push subscriptions, and queued Telegram message deletions for all users. User accounts, the audit log, and submitted feedback are preserved so the wipe itself remains traceable and bug reports stay triageable.",
"deleteButton": "Delete all data",
+ "deleteAllPromptLabel": "Type {token} to continue",
"finalDelete": "Delete permanently",
"deletedResult": "Deleted: {measurements} measurements, {medications} medications, {intakeEvents} intakes, {apiTokens} API tokens, {withingsConnections} Withings connections, {authChallenges} auth challenges, {notificationChannels} notification channels, {pushSubscriptions} push subscriptions, {telegramScheduledDeletions} queued Telegram deletions. Feedback rows preserved.",
"authRegister": "Registration",
@@ -5273,17 +5205,9 @@
"title": "Integrations",
"subtitle": "Configure Umami, GlitchTip, Web Push, and bug reports."
},
- "ai-quality": {
- "title": "Insights Quality",
- "subtitle": "Helpfulness scores grouped by severity and provider, from the last 30 days of user feedback."
- },
- "assistant": {
- "title": "Assistant Surfaces",
- "subtitle": "Toggle each model-driven surface on or off without removing the provider configuration."
- },
- "coach-feedback": {
- "title": "Coach Feedback",
- "subtitle": "Helpful-rate per Coach prompt version, tone, and verbosity from the last 30 days."
+ "coach": {
+ "title": "Coach",
+ "subtitle": "Assistant surfaces, server key, and the Coach and Insights feedback-quality tables in one place."
},
"feedback": {
"title": "Feedback",
@@ -5875,11 +5799,6 @@
"sixDaysAgo": "6 days ago"
}
},
- "relativeDay": {
- "today": "today",
- "yesterday": "yesterday",
- "daysAgo": "{count} days ago"
- },
"edit": {
"openLabel": "Edit target range for {metric}",
"title": "Edit target range — {metric}",
@@ -6344,7 +6263,9 @@
"clear": "None",
"temperatureSection": "Temperature & ovulation",
"intimacySection": "Intimacy & contraception",
- "testsSection": "Tests"
+ "testsSection": "Tests",
+ "phaseDayHeader": "Day {day} · {phase}",
+ "bbtHint": "Take it first thing in the morning, before getting up."
},
"testResult": {
"POSITIVE": "Positive",
@@ -6420,6 +6341,24 @@
"ovulationEstimate": "Est. ovulation",
"tooltipTemp": "{temp} °C",
"ovulationConfirmed": "Confirmed ovulation"
+ },
+ "fieldInfo": {
+ "bbt": "Your resting temperature, taken first thing in the morning before getting up. It rises slightly (about 0.2–0.3 °C) after ovulation.",
+ "bbtLabel": "What is basal body temperature?",
+ "mucus": "Vaginal discharge changes across the cycle — from dry or sticky to creamy, then watery and egg-white-like around the most fertile days.",
+ "mucusLabel": "What is cervical mucus?",
+ "ovulationTest": "A home urine test that detects the LH surge — the hormone spike about 24–36 h before ovulation. A positive flags the most fertile window.",
+ "ovulationTestLabel": "What is an ovulation test?",
+ "spotting": "Light bleeding outside the period.",
+ "spottingLabel": "What is spotting between periods?",
+ "pregnancyTest": "Result of a home pregnancy (hCG) test.",
+ "pregnancyTestLabel": "What is a pregnancy test?",
+ "progesteroneTest": "A test confirming ovulation has occurred — progesterone rises afterward.",
+ "progesteroneTestLabel": "What is a progesterone test?",
+ "intercourse": "Whether sexual intercourse took place on this day.",
+ "intercourseLabel": "What does intercourse track?",
+ "protected": "Whether contraception was used during intercourse.",
+ "protectedLabel": "What does protected mean?"
}
},
"cycleReminders": {
@@ -6478,7 +6417,19 @@
"BLOOD_GLUCOSE": "Blood glucose",
"OXYGEN_SATURATION": "Oxygen saturation",
"BODY_FAT": "Body fat",
- "BODY_TEMPERATURE": "Body temperature"
+ "BODY_TEMPERATURE": "Body temperature",
+ "FAT_MASS": "Fat mass",
+ "FAT_FREE_MASS": "Fat-free mass",
+ "MUSCLE_MASS": "Muscle mass",
+ "LEAN_BODY_MASS": "Lean body mass",
+ "BONE_MASS": "Bone mass",
+ "TOTAL_BODY_WATER": "Body water",
+ "VISCERAL_FAT": "Visceral fat",
+ "BODY_MASS_INDEX": "Body mass index (BMI)"
+ },
+ "typeGroups": {
+ "vitals": "Vitals",
+ "bodyComposition": "Body composition"
},
"empty": {
"title": "No reminders yet",
@@ -6495,16 +6446,22 @@
"notifyHour": "Remind at",
"location": "Where (optional)",
"locationPlaceholder": "e.g. GP, Lab",
- "save": "Save"
+ "save": "Save",
+ "createTitle": "New reminder",
+ "editTitle": "Edit reminder"
},
"pushTitle": "Preventive-care reminder",
"pushBody": "Time for: {label}",
- "pushLocation": "(at {location})"
+ "pushLocation": "(at {location})",
+ "edit": "Edit",
+ "originCoach": "Coach",
+ "enabledToggleAria": "Enable or disable this reminder",
+ "until": "Until {date}"
},
"labs": {
"title": "Lab results",
"subtitle": "Bloodwork and biomarker readings",
- "addResult": "Add",
+ "addResult": "Add value",
"addDescription": "Record a biomarker reading from a lab report.",
"addFirst": "Add your first result",
"emptyTitle": "No lab results yet",
@@ -6539,12 +6496,130 @@
"referenceHint": "Copy the reference range from your lab report. Either bound is optional.",
"note": "Note",
"notePlaceholder": "Optional context for this result",
- "requiredError": "Biomarker, value and unit are required.",
+ "requiredError": "Enter a value.",
"rangeOrderError": "The reference low must not exceed the reference high.",
"save": "Save",
"savedToast": "Result saved",
- "saveError": "Could not save the result"
- }
+ "saveError": "Could not save the result",
+ "biomarker": "Biomarker",
+ "biomarkerPlaceholder": "Pick a biomarker",
+ "defineNew": "+ Define new biomarker",
+ "noBiomarkersHint": "Define a biomarker first to record a value against it.",
+ "pickBiomarkerError": "Pick a biomarker first."
+ },
+ "backToLabs": "Back to lab results",
+ "hasNote": "Note",
+ "editTitle": "Edit reading",
+ "editDescription": "Correct the value, date or note of this reading.",
+ "editedToast": "Reading updated",
+ "editError": "Could not update the reading",
+ "editTimestampError": "Enter a valid date.",
+ "editReading": "Edit reading",
+ "deleteReading": "Delete reading",
+ "restoredToast": "Reading restored",
+ "restoreError": "Could not restore the reading",
+ "detail": {
+ "history": "History",
+ "emptyTitle": "No readings yet",
+ "emptyDescription": "Add a value to start tracking this biomarker over time."
+ },
+ "chart": {
+ "empty": "No readings in this period.",
+ "valueLabel": "Value",
+ "range": {
+ "30": "30d",
+ "90": "90d",
+ "365": "1y",
+ "all": "All"
+ }
+ },
+ "biomarker": {
+ "manage": "Biomarkers",
+ "manageTitle": "Biomarkers",
+ "manageSheetDescription": "Define the markers you track. Each is set up once with its unit and reference range.",
+ "managerDescription": "Define a marker once — its unit and range are reused for every reading.",
+ "define": "Define",
+ "defineFirst": "Define your first biomarker",
+ "defineTitle": "Define biomarker",
+ "defineDescription": "Set the name, unit and reference range once. You pick the marker when adding a value.",
+ "editTitle": "Edit biomarker",
+ "edit": "Edit",
+ "delete": "Delete",
+ "deletedToast": "Biomarker deleted",
+ "deleteError": "Could not delete the biomarker",
+ "deleteConfirmTitle": "Delete biomarker?",
+ "deleteConfirmDescription": "The definition is removed. Existing readings are kept but unlinked.",
+ "emptyTitle": "No biomarkers yet",
+ "emptyDescription": "Define markers like LDL or HbA1c to record values against them.",
+ "loadError": "Could not load biomarkers",
+ "noRange": "No reference range",
+ "form": {
+ "seedLabel": "Start from a common marker",
+ "seedPlaceholder": "Choose a marker (optional)",
+ "seedHint": "Picking one fills the fields below — adjust the range to match your lab.",
+ "name": "Name",
+ "namePlaceholder": "e.g. LDL cholesterol",
+ "unit": "Unit",
+ "unitPlaceholder": "e.g. mg/dL",
+ "panel": "Panel",
+ "panelPlaceholder": "e.g. Lipids",
+ "lowerBound": "Reference low",
+ "upperBound": "Reference high",
+ "lowerPlaceholder": "Lower bound",
+ "upperPlaceholder": "Upper bound",
+ "context": "Context",
+ "contextPlaceholder": "Optional note about this marker",
+ "rangeHint": "Either bound is optional — leave one blank for an open-ended range (e.g. LDL < 116).",
+ "requiredError": "Name and unit are required.",
+ "rangeOrderError": "The lower bound must not exceed the upper bound.",
+ "save": "Save biomarker",
+ "savedToast": "Biomarker saved",
+ "updatedToast": "Biomarker updated",
+ "saveError": "Could not save the biomarker"
+ }
+ },
+ "catalog": {
+ "total-cholesterol": "Total cholesterol",
+ "ldl": "LDL cholesterol",
+ "hdl": "HDL cholesterol",
+ "triglycerides": "Triglycerides",
+ "fasting-glucose": "Fasting glucose",
+ "hba1c": "HbA1c",
+ "tsh": "TSH",
+ "ft3": "Free T3",
+ "ft4": "Free T4",
+ "ferritin": "Ferritin",
+ "transferrin-saturation": "Transferrin saturation",
+ "vitamin-d": "Vitamin D (25-OH)",
+ "vitamin-b12": "Vitamin B12",
+ "folate": "Folate",
+ "hs-crp": "hs-CRP",
+ "creatinine": "Creatinine",
+ "egfr": "eGFR",
+ "alt": "ALT",
+ "ast": "AST",
+ "ggt": "GGT",
+ "sodium": "Sodium",
+ "potassium": "Potassium",
+ "hemoglobin": "Hemoglobin",
+ "hematocrit": "Hematocrit",
+ "wbc": "White blood cells",
+ "platelets": "Platelets",
+ "panel": {
+ "lipids": "Lipids",
+ "glucose": "Glucose metabolism",
+ "thyroid": "Thyroid",
+ "iron": "Iron",
+ "vitamins": "Vitamins",
+ "inflammation": "Inflammation",
+ "renal": "Kidney",
+ "liver": "Liver",
+ "electrolytes": "Electrolytes",
+ "bloodCount": "Blood count"
+ }
+ },
+ "noteLoadFailed": "Note could not be loaded; it will be kept unchanged when you save.",
+ "showingLatestOf": "Showing the latest {shown} of {total} readings"
},
"modules": {
"cycle": {
@@ -6606,6 +6681,165 @@
"medications": {
"label": "Medications",
"description": "Schedules, intakes, and compliance."
+ },
+ "illness": {
+ "label": "Illness journal",
+ "description": "Track illness and condition episodes with a daily symptom timeline."
+ }
+ },
+ "illness": {
+ "title": "Illness journal",
+ "subtitle": "A retrospective log of your illness and condition episodes.",
+ "disclaimer": "A personal journal — not a medical device. It does not diagnose or predict.",
+ "newEpisode": "New episode",
+ "logDay": "Log a day",
+ "markRecovered": "Mark recovered",
+ "onsetOn": "Started {date}",
+ "recoveredOn": "recovered {date}",
+ "status": {
+ "active": "Active",
+ "ongoing": "Ongoing",
+ "recovered": "Recovered"
+ },
+ "empty": {
+ "title": "No episodes yet",
+ "body": "Start an episode to keep a daily symptom timeline and look back on how it unfolded."
+ },
+ "type": {
+ "INFECTION": "Infection",
+ "ALLERGY": "Allergy",
+ "INJURY": "Injury",
+ "MENTAL_HEALTH": "Mental health",
+ "AUTOIMMUNE": "Autoimmune",
+ "CHRONIC": "Chronic",
+ "OTHER": "Other"
+ },
+ "lifecycle": {
+ "ACUTE": "Acute",
+ "CHRONIC_ONGOING": "Chronic / ongoing",
+ "RECURRING": "Recurring",
+ "FLARE": "Flare"
+ },
+ "new": {
+ "title": "New episode",
+ "description": "Record an illness or condition. You can log daily symptoms afterwards.",
+ "label": "Name",
+ "labelPlaceholder": "e.g. Common cold",
+ "type": "Type",
+ "lifecycle": "Course",
+ "onset": "Started on",
+ "note": "Note",
+ "notePlaceholder": "Anything worth remembering (encrypted).",
+ "saveError": "Could not save the episode. Please try again.",
+ "parent": "Part of",
+ "parentNone": "Not linked"
+ },
+ "sheet": {
+ "title": "Log {date}",
+ "description": "Record today's symptoms, how you function, and any fever.",
+ "symptoms": "Symptoms",
+ "functionalImpact": "How you function",
+ "fever": "Temperature",
+ "note": "Note",
+ "noteSet": "Set",
+ "notePlaceholder": "How the day felt (encrypted).",
+ "severityFor": "Severity for {symptom}",
+ "saveError": "Could not save the day. Please try again.",
+ "day": "Day"
+ },
+ "impact": {
+ "0": "Fully functional",
+ "1": "Slightly limited",
+ "2": "Mostly resting",
+ "3": "Bedbound"
+ },
+ "symptom": {
+ "runnyNose": "Runny nose",
+ "stuffyNose": "Stuffy nose",
+ "sneezing": "Sneezing",
+ "soreThroat": "Sore throat",
+ "cough": "Cough",
+ "headache": "Headache",
+ "bodyAches": "Body aches",
+ "fatigue": "Fatigue"
+ },
+ "correlation": {
+ "title": "How it unfolded",
+ "learning": "Still learning — log a few more days to see patterns here.",
+ "preOnsetTitle": "How it announced itself",
+ "preOnsetBody": "Signals that drifted from your baseline before you marked the onset.",
+ "nadirTitle": "What changed",
+ "nadirBody": "How far each tracked signal strayed from your typical range.",
+ "recoveryGapTitle": "Recovery gap",
+ "recoveryGapLag": "Your numbers settled {days} days after you felt better.",
+ "recoveryGapLead": "Your numbers settled {days} days before you marked recovery.",
+ "recoveryGapSame": "Your numbers settled the same day you felt better.",
+ "redFlagTitle": "Worth a closer look",
+ "redFlagSpo2": "Your oxygen saturation stayed low for several days. If this recurs, seek medical care.",
+ "redFlagFever": "You ran a sustained fever. If this recurs, seek medical care.",
+ "direction": {
+ "above": "above your usual",
+ "below": "below your usual"
+ }
+ },
+ "insights": {
+ "title": "Your illness history",
+ "countOne": "You logged 1 episode in this period.",
+ "countOther": "You logged {count} episodes in this period.",
+ "typicalGap": "Typical recovery gap: {days} days between feeling better and your numbers settling.",
+ "typicalGapLearning": "A few more resolved episodes and a typical recovery gap will appear here.",
+ "byMonth": "When you tend to log episodes",
+ "none": "No episodes logged in this period."
+ },
+ "loadError": "Could not load this episode.",
+ "backToList": "Back to journal",
+ "detail": {
+ "note": "Note"
+ },
+ "edit": {
+ "title": "Edit episode",
+ "description": "Update this episode's details."
+ },
+ "menu": {
+ "label": "Episode actions"
+ },
+ "deleteConfirm": {
+ "title": "Delete this episode?",
+ "body": "“{label}” and all its day-logs will be removed. This cannot be undone."
+ },
+ "vital": {
+ "RESTING_HEART_RATE": "Resting heart rate",
+ "HEART_RATE_VARIABILITY": "Heart-rate variability",
+ "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",
+ "PULSE": "Pulse",
+ "WEIGHT": "Weight"
+ }
+ },
+ "coach": {
+ "reminderSuggestion": {
+ "title": "Set a reminder?",
+ "accept": "Set reminder",
+ "dismiss": "Not now",
+ "stop": "I measure enough",
+ "accepted": "Reminder set",
+ "dismissed": "Okay, I won't suggest this again",
+ "stopped": "Got it — I'll stop suggesting reminders",
+ "failed": "Couldn't set that up — please try again",
+ "cadence": {
+ "weightDaily": "Weigh yourself daily, around the same time",
+ "bp722": "Measure your blood pressure twice a day for a week",
+ "glucoseStructured": "Test your glucose on a structured daily schedule"
+ }
+ },
+ "settings": {
+ "reminderSuggestionsLabel": "Cadence suggestions",
+ "reminderSuggestionsDescription": "Let the Coach offer an occasional, evidence-based reminder to measure a metric more regularly."
}
}
}
diff --git a/messages/es.json b/messages/es.json
index 083fee1bc..53f0e1d22 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -123,37 +123,6 @@
"glucoseBelowTarget": "por debajo del objetivo",
"glucoseAboveTarget": "por encima del objetivo",
"practiceLabel": "Consulta",
- "dialog": {
- "title": "Informe médico (PDF)",
- "description": "Define el periodo de análisis y (opcional) el nombre de la consulta — ambos aparecen en la portada.",
- "startLabel": "Desde",
- "endLabel": "Hasta",
- "practiceLabel": "Consulta / clínica (opcional)",
- "practicePlaceholder": "p. ej. Dr. Müller — Medicina familiar",
- "preset90d": "Últimos 90 días",
- "preset180d": "Últimos 6 meses",
- "preset365d": "Últimos 12 meses",
- "submit": "Generar PDF",
- "cancel": "Cancelar",
- "errorEndBeforeStart": "La fecha final no puede ser anterior a la inicial.",
- "errorRangeTooLong": "El periodo de análisis no puede superar los 2 años.",
- "errorInvalidDate": "Selecciona una fecha de inicio y fin válida."
- },
- "sections": {
- "title": "Seleccionar contenido",
- "bp": "Presión arterial",
- "weight": "Peso",
- "pulse": "Pulso en reposo",
- "bmi": "Índice de masa corporal (IMC)",
- "mood": "Estado de ánimo",
- "moodSensitive": "Sensible — desactivado por defecto.",
- "compliance": "Cumplimiento de medicación",
- "sleep": "Duración del sueño",
- "cycle": "Ciclo menstrual",
- "empty": "No hay datos para el periodo seleccionado. El informe se generará con la portada.",
- "unavailableHint": "Sin datos en este periodo",
- "labs": "Resultados de análisis"
- },
"insurer": "Seguro médico",
"insuranceNumber": "N.º de asegurado",
"summaryTitle": "Resumen clínico",
@@ -204,7 +173,14 @@
"eA1c": "A1C estimada",
"cv": "Variabilidad (CV%)",
"spotCaveat": "Son estimaciones a partir de mediciones puntuales, no un perfil de monitor continuo — léelas como una tendencia, no como un veredicto clínico."
- }
+ },
+ "illnessTitle": "Afecciones y enfermedades",
+ "illnessOngoing": "En curso",
+ "illnessColCondition": "Afección",
+ "illnessColType": "Tipo",
+ "illnessColLifecycle": "Curso",
+ "illnessColOnset": "Inicio",
+ "illnessColResolved": "Resuelta"
},
"errorBoundary": {
"title": "Algo ha salido mal",
@@ -266,7 +242,8 @@
"cycle": "Ciclo",
"labs": "Análisis",
"recovery": "Recuperación",
- "vorsorge": "Prevención"
+ "vorsorge": "Prevención",
+ "illness": "Enfermedad"
},
"auth": {
"login": "Iniciar sesión",
@@ -1632,17 +1609,7 @@
"copyToken": "Copiar token",
"mintedHint": "Copia este token ahora — solo se muestra una vez."
},
- "edit": {
- "planOption": "Editar plan",
- "advancedOption": "Ajustes avanzados"
- },
"advanced": {
- "title": "Ajustes avanzados",
- "group": {
- "data": "Datos",
- "lifecycle": "Ciclo de vida",
- "externalApi": "API externa"
- },
"dataPortability": {
"import": {
"title": "Importar tomas",
@@ -2188,23 +2155,6 @@
"confidenceLow": "Confianza baja",
"pendingLabel": "Generando comentario de tendencia"
},
- "range": {
- "label": "Período",
- "noPriorData": "sin datos del período anterior",
- "option": {
- "7d": "7d",
- "30d": "30d",
- "90d": "90d",
- "1y": "1a"
- },
- "priorLabel": {
- "7d": "vs 7d anteriores",
- "30d": "vs 30d anteriores",
- "90d": "vs 90d anteriores",
- "1y": "vs año anterior"
- },
- "noChange": "sin cambios"
- },
"correlationRow": {
"title": "Correlaciones",
"disclaimer": "Las correlaciones son observacionales, no causales — habla con tu médico si tu tensión es alta.",
@@ -2546,14 +2496,6 @@
"target": {
"adjustLink": "Ajustar el rango objetivo"
},
- "primary": {
- "avg30Label": "Promedio de 30 días",
- "inRangeLabel": "En el objetivo",
- "inRangeAria": "{pct}% de los días registrados dentro del objetivo"
- },
- "lastMeasurement": {
- "label": "Última medición"
- },
"explainer": {
"trigger": "¿Qué es {title}?",
"bloodPressureTitle": "Presión arterial",
@@ -2646,6 +2588,8 @@
"stairDescentSpeedBody": "Es lo rápido que bajas un tramo de escaleras. Junto con la velocidad de subida refleja el control de piernas y el equilibrio: un ritmo estable es buena señal y uno que baja conviene vigilar.",
"breathingDisturbancesTitle": "Alteraciones respiratorias del sueño",
"breathingDisturbancesBody": "Tu Apple Watch puede estimar con qué frecuencia se interrumpió tu respiración mientras dormías. Un índice más bajo es mejor; un nivel que Apple marca como elevado, sobre todo durante muchas noches, conviene comentarlo con un profesional.",
+ "stepsTitle": "Pasos",
+ "stepsBody": "El recuento de pasos es el número total de pasos que das cada día. Es la medida más sencilla del movimiento cotidiano: un recuento diario más alto suele reflejar un estilo de vida más activo, y un hábito constante importa más que un único día destacado.",
"readinessBody": "La preparación es una lectura diaria de lo listo que está tu cuerpo para afrontar el día, combinando la frecuencia cardíaca en reposo, la variabilidad de la frecuencia cardíaca, el sueño y la regularidad de tus últimas noches. Una puntuación más alta indica que tu organismo se ve recuperado y con recursos; una más baja es un aviso suave para bajar el ritmo.",
"sleepScoreBody": "Tu puntuación de sueño resume toda una noche en un solo número — duración, eficiencia, regularidad y el equilibrio entre sueño ligero, profundo y REM. Fíjate en el patrón a lo largo de las semanas en lugar de perseguir una puntuación perfecta una sola noche.",
"recoveryBody": "La recuperación refleja lo bien que tu cuerpo se ha repuesto, a partir de señales nocturnas como la variabilidad de la frecuencia cardíaca y la frecuencia en reposo. Una puntuación alta es luz verde para exigirte; una más baja sugiere que tu organismo aún se está reconstruyendo.",
@@ -2750,6 +2694,11 @@
"critical": "Crítico"
},
"assessmentTitle": "Valoración",
+ "restMode": {
+ "title": "Modo descanso",
+ "body": "Hay un episodio de enfermedad o afección activo, por lo que esto refleja un periodo de recuperación. Tu puntuación no baja por ello.",
+ "bodySince": "Hay un episodio de enfermedad o afección activo desde el {since}, por lo que esto refleja un periodo de recuperación. Tu puntuación no baja por ello."
+ },
"assessmentShowMore": "Mostrar más",
"assessmentShowLess": "Mostrar menos",
"bloodPressureSectionTitle": "Presión arterial",
@@ -3461,7 +3410,8 @@
"watchVital": "Fuera de rango: {vital}. A menudo es algo puntual; conviene vigilarlo los próximos días.",
"firedHeadline": "Hoy, {count} de tus constantes están fuera de su rango personal.",
"factors": "Posibles factores, nunca una causa: un entrenamiento intenso, dormir mal, alcohol, altitud, estrés o una enfermedad. Es un aviso a partir de tus propias bases, no un diagnóstico.",
- "building": "Construyendo tus bases personales: registra unos días más y esto aparecerá."
+ "building": "Construyendo tus bases personales: registra unos días más y esto aparecerá.",
+ "illnessExplained": "Es probable que estas variaciones reflejen la afección que registraste."
},
"trajectory": {
"cardTitle": "Proyección a corto plazo",
@@ -3633,10 +3583,14 @@
"title": "Corazón y energía del día",
"subtitle": "Totales cardiacos y de energía de todo el día"
},
- "scoreLink": {
- "title": "Puntuación de recuperación",
- "subtitle": "Mira cómo se descompone la puntuación nocturna",
- "cta": "Ver"
+ "block": {
+ "ansChargeExplainer": "Cuán cargado está tu sistema nervioso autónomo para el día — una lectura de recarga obtenida de tus señales nocturnas de corazón y respiración.",
+ "dayStrainExplainer": "La carga cardiovascular del día en la escala de esfuerzo de tu dispositivo. Léela en relación con tu recuperación, no como una meta en sí.",
+ "workoutStrainExplainer": "Cuán exigente fue un entrenamiento en la misma escala de esfuerzo — el coste cardiovascular de esa sesión.",
+ "cardioLoadExplainer": "Una aproximación de la carga de entrenamiento que sigue la carga cardiovascular acumulada. Compárala con tus propios días recientes.",
+ "averageHeartRateExplainer": "Tu frecuencia cardíaca media a lo largo de todo el día o ciclo, según informa tu wearable.",
+ "maxHeartRateExplainer": "Tu frecuencia cardíaca máxima a lo largo de todo el día o ciclo, según informa tu wearable.",
+ "energyExpenditureKjExplainer": "La energía que tu cuerpo gastó durante el día, en kilojulios, tal como tu dispositivo la informa de forma nativa."
}
},
"bodyTempDeviation": {
@@ -3871,18 +3825,7 @@
},
"integrations": {
"title": "Integraciones",
- "description": "Servicios conectados, canales de entrega y prioridad de fuentes en un solo lugar.",
- "tabs": {
- "connections": "Conexiones",
- "channels": "Canales",
- "sources": "Fuentes"
- },
- "connections": {
- "description": "Conecta Withings, WHOOP, Fitbit, Polar, Oura y Nightscout."
- },
- "channels": {
- "description": "Telegram, ntfy, Web Push, Webhook y correo: las vías por las que se entregan las notificaciones."
- }
+ "description": "Servicios conectados, canales de entrega y prioridad de fuentes en un solo lugar."
},
"notifications": {
"title": "Notificaciones",
@@ -3892,7 +3835,7 @@
"inboxLink": "Abrir la bandeja de entrada"
},
"layout": {
- "title": "Diseño y personalización",
+ "title": "Diseño",
"description": "Organiza cómo están dispuestos el panel y los análisis.",
"backToHub": "Volver a Diseño y personalización",
"dashboard": {
@@ -3902,14 +3845,6 @@
"insights": {
"title": "Análisis",
"description": "Orden de la vista general y de las pestañas de navegación."
- },
- "medications": {
- "title": "Medicamentos",
- "description": "Vista de lista y orden de los medicamentos."
- },
- "mood": {
- "title": "Etiquetas de ánimo",
- "description": "Grupos, etiquetas y orden del selector de ánimo."
}
},
"dashboard": {
@@ -3996,12 +3931,6 @@
"description": "Descarga tus datos como CSV o una copia de seguridad JSON completa, o importa desde Apple Health, JSON o CSV. El informe médico ahora está en Historia de salud.",
"otherOptionsHeading": "Otras opciones de exportación",
"downloadFailed": "No se pudo descargar la exportación. Inténtalo de nuevo.",
- "hero": {
- "eyebrow": "Cita médica",
- "valueStatement": "Un informe PDF imprimible para tu cita médica — constantes, IMC, clasificación de la tensión, cumplimiento de la medicación y (opcionalmente) el estado de ánimo, en pocas páginas.",
- "cta": "Configurar y generar informe",
- "formatHint": "PDF · listo para imprimir"
- },
"actions": {
"download": "Descargar"
},
@@ -4010,10 +3939,6 @@
"until": "Hasta"
},
"cards": {
- "doctorReport": {
- "title": "Informe médico",
- "description": "PDF imprimible para la cita médica — constantes vitales, IMC, clasificación de la presión arterial, cumplimiento de la medicación, estado de ánimo."
- },
"measurementsCsv": {
"title": "Mediciones",
"description": "Peso, presión arterial, pulso, glucosa y todas las demás mediciones como valores separados por comas."
@@ -4146,6 +4071,10 @@
"coach": {
"title": "Coach",
"description": "Activa el Coach, tono y alcance, recuerdos guardados."
+ },
+ "channels": {
+ "title": "Canales",
+ "description": "Envío por Telegram, ntfy, Web Push, Webhook y correo electrónico."
}
},
"profile": "Perfil",
@@ -4387,14 +4316,6 @@
"clearedToast": "Se olvidaron {count} datos guardados.",
"clearError": "No se pudieron borrar los datos guardados. Inténtalo de nuevo."
},
- "disableCoach": {
- "title": "Ocultar el Coach",
- "description": "Oculta el botón del Coach y el panel en todas las páginas.",
- "toggleAria": "Ocultar el botón del Coach y el panel",
- "savedHidden": "Coach oculto.",
- "savedShown": "Coach visible.",
- "saveError": "No se pudo guardar el cambio. Inténtalo de nuevo."
- },
"aboutMe": {
"title": "Sobre mí",
"description": "Contexto personal que el Coach y el informe diario leen junto con tus datos.",
@@ -4857,7 +4778,17 @@
"rate_limited": "Demasiados intentos de conexión. Espera un momento e inténtalo de nuevo.",
"generic": "Error desconocido al conectar. Inicia la conexión de nuevo."
},
- "integrationSetupGuide": "Guía de configuración"
+ "integrationSetupGuide": "Guía de configuración",
+ "coach": {
+ "activate": {
+ "title": "Activar el Coach",
+ "description": "Muestra el botón y el panel del Coach. Activado por defecto: desactívalo para ocultar el Coach en todas partes.",
+ "toggleAria": "Activar el botón y el panel del Coach",
+ "savedOn": "Coach activado.",
+ "savedOff": "Coach oculto.",
+ "saveError": "No se pudo guardar el cambio. Inténtalo de nuevo."
+ }
+ }
},
"admin": {
"title": "Administration",
@@ -5145,6 +5076,7 @@
"deleteAllConfirm": "¿Eliminar realmente todos los datos?",
"deleteAllConfirmDescription": "Esto eliminará permanentemente todas las mediciones, medicamentos, eventos de toma, tokens de API, conexiones de Withings, retos de autenticación abiertos, canales de notificación (Telegram, ntfy, Web Push), suscripciones push y eliminaciones de mensajes de Telegram en cola de todos los usuarios. Las cuentas de usuario, el registro de auditoría y los comentarios enviados se conservan para que el borrado siga siendo rastreable y los informes de errores se puedan seguir clasificando.",
"deleteButton": "Eliminar todos los datos",
+ "deleteAllPromptLabel": "Escribe {token} para continuar",
"finalDelete": "Eliminar permanentemente",
"deletedResult": "Deleted: {measurements} measurements, {medications} Medicamentos,{intakeEvents} intakes, {apiTokens} API tokens, {withingsConnections} Withings connections, {authChallenges} auth challenges, {notificationChannels} notification channels, {pushSubscriptions} push subscriptions, {telegramScheduledDeletions} queued Telegram deletions. Feedback rows preserved.",
"authRegister": "Registro",
@@ -5273,17 +5205,9 @@
"title": "Integraciones",
"subtitle": "Configura Umami, GlitchTip, Web Push e informes de errores."
},
- "ai-quality": {
- "title": "Calidad de los análisis",
- "subtitle": "Puntuaciones de utilidad agrupadas por gravedad y proveedor, de los últimos 30 días de comentarios de usuarios."
- },
- "assistant": {
- "title": "Superficies del asistente",
- "subtitle": "Activa o desactiva cada superficie impulsada por el modelo sin retirar la configuración del proveedor."
- },
- "coach-feedback": {
- "title": "Comentarios del Coach",
- "subtitle": "Tasa de utilidad por versión de prompt, tono y nivel de detalle del Coach de los últimos 30 días."
+ "coach": {
+ "title": "Coach",
+ "subtitle": "Superficies del asistente, clave del servidor y las tablas de calidad de comentarios del Coach y de los análisis, en un solo lugar."
},
"feedback": {
"title": "Comentarios",
@@ -5875,11 +5799,6 @@
"sixDaysAgo": "hace 6 días"
}
},
- "relativeDay": {
- "today": "Hoy",
- "yesterday": "Ayer",
- "daysAgo": "hace {count} días"
- },
"edit": {
"openLabel": "Editar el rango objetivo de {metric}",
"title": "Editar rango objetivo — {metric}",
@@ -6344,7 +6263,9 @@
"delete": "Eliminar",
"temperatureSection": "Temperatura y ovulación",
"intimacySection": "Intimidad y anticoncepción",
- "testsSection": "Pruebas"
+ "testsSection": "Pruebas",
+ "phaseDayHeader": "Día {day} · {phase}",
+ "bbtHint": "Mídela a primera hora de la mañana, antes de levantarte."
},
"settings": {
"title": "Ajustes del ciclo",
@@ -6420,6 +6341,24 @@
"ovulationEstimate": "Ovulación est.",
"tooltipTemp": "{temp} °C",
"ovulationConfirmed": "Ovulación confirmada"
+ },
+ "fieldInfo": {
+ "bbt": "Tu temperatura en reposo, medida a primera hora de la mañana antes de levantarte. Sube ligeramente (unos 0,2–0,3 °C) tras la ovulación.",
+ "bbtLabel": "¿Qué es la temperatura basal?",
+ "mucus": "El flujo vaginal cambia a lo largo del ciclo: de seco o pegajoso a cremoso, y luego acuoso y como clara de huevo en los días más fértiles.",
+ "mucusLabel": "¿Qué es el moco cervical?",
+ "ovulationTest": "Una prueba de orina casera que detecta el pico de LH, el aumento hormonal unas 24–36 h antes de la ovulación. Un positivo señala la ventana más fértil.",
+ "ovulationTestLabel": "¿Qué es una prueba de ovulación?",
+ "spotting": "Sangrado leve fuera de la menstruación.",
+ "spottingLabel": "¿Qué es el manchado entre periodos?",
+ "pregnancyTest": "Resultado de una prueba de embarazo casera (hCG).",
+ "pregnancyTestLabel": "¿Qué es una prueba de embarazo?",
+ "progesteroneTest": "Una prueba que confirma que ha ocurrido la ovulación: la progesterona sube después.",
+ "progesteroneTestLabel": "¿Qué es una prueba de progesterona?",
+ "intercourse": "Si hubo relaciones sexuales este día.",
+ "intercourseLabel": "¿Qué registra relaciones?",
+ "protected": "Si se usó anticoncepción durante las relaciones.",
+ "protectedLabel": "¿Qué significa protegido?"
}
},
"cycleReminders": {
@@ -6478,7 +6417,19 @@
"BLOOD_GLUCOSE": "Glucosa en sangre",
"OXYGEN_SATURATION": "Saturación de oxígeno",
"BODY_FAT": "Grasa corporal",
- "BODY_TEMPERATURE": "Temperatura corporal"
+ "BODY_TEMPERATURE": "Temperatura corporal",
+ "FAT_MASS": "Masa grasa",
+ "FAT_FREE_MASS": "Masa libre de grasa",
+ "MUSCLE_MASS": "Masa muscular",
+ "LEAN_BODY_MASS": "Masa magra",
+ "BONE_MASS": "Masa ósea",
+ "TOTAL_BODY_WATER": "Agua corporal",
+ "VISCERAL_FAT": "Grasa visceral",
+ "BODY_MASS_INDEX": "Índice de masa corporal (IMC)"
+ },
+ "typeGroups": {
+ "vitals": "Constantes vitales",
+ "bodyComposition": "Composición corporal"
},
"empty": {
"title": "Aún no hay recordatorios",
@@ -6495,22 +6446,28 @@
"notifyHour": "Recordar a las",
"location": "Dónde (opcional)",
"locationPlaceholder": "p. ej. Médico, Laboratorio",
- "save": "Guardar"
+ "save": "Guardar",
+ "createTitle": "Nuevo recordatorio",
+ "editTitle": "Editar recordatorio"
},
"pushTitle": "Recordatorio de prevención",
"pushBody": "Toca: {label}",
- "pushLocation": "(en {location})"
+ "pushLocation": "(en {location})",
+ "edit": "Editar",
+ "originCoach": "Coach",
+ "enabledToggleAria": "Activar o desactivar este recordatorio",
+ "until": "Hasta {date}"
},
"labs": {
"title": "Resultados de análisis",
"subtitle": "Analíticas de sangre y biomarcadores",
- "addResult": "Añadir",
+ "addResult": "Añadir valor",
"addDescription": "Registra un biomarcador a partir de un informe de laboratorio.",
"addFirst": "Añade tu primer resultado",
"emptyTitle": "Aún no hay resultados",
"emptyDescription": "Registra analíticas como HbA1c, LDL o ferritina para seguirlas a lo largo del tiempo y compartirlas con tu médico.",
"referenceLabel": "Referencia",
- "readingsCount": "{count} valores",
+ "readingsCount": "{count} mediciones",
"deleteLatest": "Eliminar el último valor",
"deleteConfirmTitle": "¿Eliminar resultado de laboratorio?",
"deleteConfirmDescription": "Este resultado de laboratorio se eliminará de forma permanente.",
@@ -6539,12 +6496,130 @@
"referenceHint": "Copia el rango de referencia de tu informe de laboratorio. Cualquiera de los límites es opcional.",
"note": "Nota",
"notePlaceholder": "Contexto opcional para este resultado",
- "requiredError": "El biomarcador, el valor y la unidad son obligatorios.",
+ "requiredError": "Introduce un valor.",
"rangeOrderError": "La referencia mínima no puede superar la máxima.",
"save": "Guardar",
"savedToast": "Resultado guardado",
- "saveError": "No se pudo guardar el resultado"
- }
+ "saveError": "No se pudo guardar el resultado",
+ "biomarker": "Biomarcador",
+ "biomarkerPlaceholder": "Elige un biomarcador",
+ "defineNew": "+ Definir nuevo biomarcador",
+ "noBiomarkersHint": "Define primero un biomarcador para registrar un valor.",
+ "pickBiomarkerError": "Elige primero un biomarcador."
+ },
+ "backToLabs": "Volver a los resultados",
+ "hasNote": "Nota",
+ "editTitle": "Editar medición",
+ "editDescription": "Corrige el valor, la fecha o la nota de esta medición.",
+ "editedToast": "Medición actualizada",
+ "editError": "No se pudo actualizar la medición",
+ "editTimestampError": "Introduce una fecha válida.",
+ "editReading": "Editar medición",
+ "deleteReading": "Eliminar medición",
+ "restoredToast": "Medición restaurada",
+ "restoreError": "No se pudo restaurar la medición",
+ "detail": {
+ "history": "Historial",
+ "emptyTitle": "Aún no hay mediciones",
+ "emptyDescription": "Añade un valor para seguir este biomarcador con el tiempo."
+ },
+ "chart": {
+ "empty": "Sin mediciones en este periodo.",
+ "valueLabel": "Valor",
+ "range": {
+ "30": "30 d",
+ "90": "90 d",
+ "365": "1 a",
+ "all": "Todo"
+ }
+ },
+ "biomarker": {
+ "manage": "Biomarcadores",
+ "manageTitle": "Biomarcadores",
+ "manageSheetDescription": "Define los marcadores que sigues. Cada uno se configura una vez con su unidad y rango de referencia.",
+ "managerDescription": "Define un marcador una vez: su unidad y rango se reutilizan en cada medición.",
+ "define": "Definir",
+ "defineFirst": "Define tu primer biomarcador",
+ "defineTitle": "Definir biomarcador",
+ "defineDescription": "Configura el nombre, la unidad y el rango una vez. Eliges el marcador al añadir un valor.",
+ "editTitle": "Editar biomarcador",
+ "edit": "Editar",
+ "delete": "Eliminar",
+ "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.",
+ "emptyTitle": "Aún no hay biomarcadores",
+ "emptyDescription": "Define marcadores como LDL o HbA1c para registrar valores.",
+ "loadError": "No se pudieron cargar los biomarcadores",
+ "noRange": "Sin rango de referencia",
+ "form": {
+ "seedLabel": "Partir de un marcador común",
+ "seedPlaceholder": "Elige un marcador (opcional)",
+ "seedHint": "Al elegir uno se rellenan los campos: ajusta el rango a tu laboratorio.",
+ "name": "Nombre",
+ "namePlaceholder": "p. ej. Colesterol LDL",
+ "unit": "Unidad",
+ "unitPlaceholder": "p. ej. mg/dL",
+ "panel": "Panel",
+ "panelPlaceholder": "p. ej. Lípidos",
+ "lowerBound": "Referencia mínima",
+ "upperBound": "Referencia máxima",
+ "lowerPlaceholder": "Límite inferior",
+ "upperPlaceholder": "Límite superior",
+ "context": "Contexto",
+ "contextPlaceholder": "Nota opcional sobre este marcador",
+ "rangeHint": "Ambos límites son opcionales: deja uno en blanco para un rango abierto (p. ej. LDL < 116).",
+ "requiredError": "El nombre y la unidad son obligatorios.",
+ "rangeOrderError": "El límite inferior no debe superar al superior.",
+ "save": "Guardar biomarcador",
+ "savedToast": "Biomarcador guardado",
+ "updatedToast": "Biomarcador actualizado",
+ "saveError": "No se pudo guardar el biomarcador"
+ }
+ },
+ "catalog": {
+ "total-cholesterol": "Colesterol total",
+ "ldl": "Colesterol LDL",
+ "hdl": "Colesterol HDL",
+ "triglycerides": "Triglicéridos",
+ "fasting-glucose": "Glucosa en ayunas",
+ "hba1c": "HbA1c",
+ "tsh": "TSH",
+ "ft3": "T3 libre",
+ "ft4": "T4 libre",
+ "ferritin": "Ferritina",
+ "transferrin-saturation": "Saturación de transferrina",
+ "vitamin-d": "Vitamina D (25-OH)",
+ "vitamin-b12": "Vitamina B12",
+ "folate": "Folato",
+ "hs-crp": "PCR ultrasensible",
+ "creatinine": "Creatinina",
+ "egfr": "TFGe",
+ "alt": "ALT",
+ "ast": "AST",
+ "ggt": "GGT",
+ "sodium": "Sodio",
+ "potassium": "Potasio",
+ "hemoglobin": "Hemoglobina",
+ "hematocrit": "Hematocrito",
+ "wbc": "Leucocitos",
+ "platelets": "Plaquetas",
+ "panel": {
+ "lipids": "Lípidos",
+ "glucose": "Metabolismo de la glucosa",
+ "thyroid": "Tiroides",
+ "iron": "Hierro",
+ "vitamins": "Vitaminas",
+ "inflammation": "Inflamación",
+ "renal": "Riñón",
+ "liver": "Hígado",
+ "electrolytes": "Electrolitos",
+ "bloodCount": "Hemograma"
+ }
+ },
+ "noteLoadFailed": "No se pudo cargar la nota; se mantendrá sin cambios al guardar.",
+ "showingLatestOf": "Mostrando los {shown} resultados más recientes de {total}"
},
"modules": {
"cycle": {
@@ -6584,7 +6659,7 @@
"description": "Chat de IA basado en tus propios datos."
},
"insights": {
- "label": "Análisis",
+ "label": "Insights",
"description": "Análisis de IA, resumen y correlaciones."
},
"doctorReport": {
@@ -6606,6 +6681,165 @@
"medications": {
"label": "Medicamentos",
"description": "Pautas, tomas y cumplimiento."
+ },
+ "illness": {
+ "label": "Diario de enfermedad",
+ "description": "Registra episodios de enfermedad y dolencias con un historial diario de síntomas."
+ }
+ },
+ "illness": {
+ "title": "Diario de enfermedad",
+ "subtitle": "Un registro retrospectivo de tus episodios de enfermedad y dolencias.",
+ "disclaimer": "Un diario personal, no un dispositivo médico. No diagnostica ni predice.",
+ "newEpisode": "Nuevo episodio",
+ "logDay": "Registrar un día",
+ "markRecovered": "Marcar como recuperado",
+ "onsetOn": "Comenzó el {date}",
+ "recoveredOn": "recuperado {date}",
+ "status": {
+ "active": "Activo",
+ "ongoing": "En curso",
+ "recovered": "Recuperado"
+ },
+ "empty": {
+ "title": "Aún no hay episodios",
+ "body": "Inicia un episodio para llevar un historial diario de síntomas y revisar cómo evolucionó."
+ },
+ "type": {
+ "INFECTION": "Infección",
+ "ALLERGY": "Alergia",
+ "INJURY": "Lesión",
+ "MENTAL_HEALTH": "Salud mental",
+ "AUTOIMMUNE": "Autoinmune",
+ "CHRONIC": "Crónica",
+ "OTHER": "Otro"
+ },
+ "lifecycle": {
+ "ACUTE": "Aguda",
+ "CHRONIC_ONGOING": "Crónica / en curso",
+ "RECURRING": "Recurrente",
+ "FLARE": "Brote"
+ },
+ "new": {
+ "title": "Nuevo episodio",
+ "description": "Registra una enfermedad o dolencia. Luego podrás anotar síntomas diarios.",
+ "label": "Nombre",
+ "labelPlaceholder": "p. ej. Resfriado común",
+ "type": "Tipo",
+ "lifecycle": "Curso",
+ "onset": "Comenzó el",
+ "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"
+ },
+ "sheet": {
+ "title": "Registrar {date}",
+ "description": "Anota los síntomas de hoy, cómo funcionas y la fiebre, si la hay.",
+ "symptoms": "Síntomas",
+ "functionalImpact": "Cómo funcionas",
+ "fever": "Temperatura",
+ "note": "Nota",
+ "noteSet": "Definida",
+ "notePlaceholder": "Cómo te sentiste hoy (cifrado).",
+ "severityFor": "Gravedad de {symptom}",
+ "saveError": "No se pudo guardar el día. Inténtalo de nuevo.",
+ "day": "Day"
+ },
+ "impact": {
+ "0": "Plenamente funcional",
+ "1": "Algo limitado",
+ "2": "Mayormente en reposo",
+ "3": "En cama"
+ },
+ "symptom": {
+ "runnyNose": "Goteo nasal",
+ "stuffyNose": "Congestión nasal",
+ "sneezing": "Estornudos",
+ "soreThroat": "Dolor de garganta",
+ "cough": "Tos",
+ "headache": "Dolor de cabeza",
+ "bodyAches": "Dolores musculares",
+ "fatigue": "Fatiga"
+ },
+ "correlation": {
+ "title": "Cómo se desarrolló",
+ "learning": "Aún aprendiendo: registra unos días más para ver patrones aquí.",
+ "preOnsetTitle": "Cómo se anunció",
+ "preOnsetBody": "Señales que se alejaron de tu base antes de marcar el inicio.",
+ "nadirTitle": "Qué cambió",
+ "nadirBody": "Cuánto se alejó cada señal registrada de tu rango habitual.",
+ "recoveryGapTitle": "Brecha de recuperación",
+ "recoveryGapLag": "Tus valores se estabilizaron {days} días después de sentirte mejor.",
+ "recoveryGapLead": "Tus valores se estabilizaron {days} días antes de marcar la recuperación.",
+ "recoveryGapSame": "Tus valores se estabilizaron el mismo día en que te sentiste mejor.",
+ "redFlagTitle": "Conviene revisarlo",
+ "redFlagSpo2": "Tu saturación de oxígeno se mantuvo baja varios días. Si se repite, busca atención médica.",
+ "redFlagFever": "Tuviste fiebre sostenida. Si se repite, busca atención médica.",
+ "direction": {
+ "above": "por encima de lo habitual",
+ "below": "por debajo de lo habitual"
+ }
+ },
+ "insights": {
+ "title": "Tu historial de enfermedades",
+ "countOne": "Registraste 1 episodio en este período.",
+ "countOther": "Registraste {count} episodios en este período.",
+ "typicalGap": "Brecha de recuperación típica: {days} días entre sentirte mejor y la estabilización de tus valores.",
+ "typicalGapLearning": "Con unos pocos episodios resueltos más aparecerá aquí una brecha de recuperación típica.",
+ "byMonth": "Cuándo sueles registrar episodios",
+ "none": "Sin episodios registrados en este período."
+ },
+ "loadError": "Could not load this episode.",
+ "backToList": "Back to journal",
+ "detail": {
+ "note": "Note"
+ },
+ "edit": {
+ "title": "Edit episode",
+ "description": "Update this episode's details."
+ },
+ "menu": {
+ "label": "Episode actions"
+ },
+ "deleteConfirm": {
+ "title": "Delete this episode?",
+ "body": "“{label}” and all its day-logs will be removed. This cannot be undone."
+ },
+ "vital": {
+ "RESTING_HEART_RATE": "Resting heart rate",
+ "HEART_RATE_VARIABILITY": "Heart-rate variability",
+ "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",
+ "PULSE": "Pulse",
+ "WEIGHT": "Weight"
+ }
+ },
+ "coach": {
+ "reminderSuggestion": {
+ "title": "¿Crear un recordatorio?",
+ "accept": "Crear recordatorio",
+ "dismiss": "Ahora no",
+ "stop": "Ya mido suficiente",
+ "accepted": "Recordatorio creado",
+ "dismissed": "De acuerdo, no lo volveré a sugerir",
+ "stopped": "Entendido, dejaré de sugerir recordatorios",
+ "failed": "No se pudo crear, inténtalo de nuevo",
+ "cadence": {
+ "weightDaily": "Pésate a diario, más o menos a la misma hora",
+ "bp722": "Mide tu tensión dos veces al día durante una semana",
+ "glucoseStructured": "Mide tu glucosa con un horario diario estructurado"
+ }
+ },
+ "settings": {
+ "reminderSuggestionsLabel": "Sugerencias de frecuencia",
+ "reminderSuggestionsDescription": "Permite que el Coach ofrezca de vez en cuando un recordatorio basado en evidencia para medir una métrica con más regularidad."
}
}
}
diff --git a/messages/fr.json b/messages/fr.json
index 9b837a2c6..a148aa1c1 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -123,37 +123,6 @@
"glucoseBelowTarget": "sous la cible",
"glucoseAboveTarget": "au-dessus de la cible",
"practiceLabel": "Cabinet",
- "dialog": {
- "title": "Rapport médical (PDF)",
- "description": "Définis la période d’analyse et (optionnel) le nom du cabinet — les deux apparaîtront sur la page de garde.",
- "startLabel": "Du",
- "endLabel": "Au",
- "practiceLabel": "Cabinet / clinique (optionnel)",
- "practicePlaceholder": "p. ex. Dr Müller — médecin généraliste",
- "preset90d": "90 derniers jours",
- "preset180d": "6 derniers mois",
- "preset365d": "12 derniers mois",
- "submit": "Générer le PDF",
- "cancel": "Annuler",
- "errorEndBeforeStart": "La date de fin ne peut pas être antérieure à la date de début.",
- "errorRangeTooLong": "La période d’analyse ne peut pas dépasser 2 ans.",
- "errorInvalidDate": "Choisis une date de début et une date de fin valides."
- },
- "sections": {
- "title": "Choisir le contenu",
- "bp": "Tension artérielle",
- "weight": "Poids",
- "pulse": "Pouls au repos",
- "bmi": "Indice de masse corporelle (IMC)",
- "mood": "Humeur",
- "moodSensitive": "Sensible — désactivé par défaut.",
- "compliance": "Observance des médicaments",
- "sleep": "Durée du sommeil",
- "cycle": "Cycle menstruel",
- "empty": "Pas de données sur la période sélectionnée. Le rapport conservera la page de garde.",
- "unavailableHint": "Pas de données sur cette période",
- "labs": "Résultats d'analyses"
- },
"insurer": "Assurance maladie",
"insuranceNumber": "N° d’assuré",
"summaryTitle": "Résumé clinique",
@@ -204,7 +173,14 @@
"eA1c": "HbA1c estimée",
"cv": "Variabilité (CV%)",
"spotCaveat": "Ce sont des estimations issues de mesures ponctuelles, et non un profil de capteur continu — à lire comme une tendance, pas comme un verdict clinique."
- }
+ },
+ "illnessTitle": "Affections et maladies",
+ "illnessOngoing": "En cours",
+ "illnessColCondition": "Affection",
+ "illnessColType": "Type",
+ "illnessColLifecycle": "Évolution",
+ "illnessColOnset": "Début",
+ "illnessColResolved": "Résolue"
},
"errorBoundary": {
"title": "Une erreur est survenue",
@@ -266,7 +242,8 @@
"cycle": "Cycle",
"labs": "Analyses",
"recovery": "Récupération",
- "vorsorge": "Prévention"
+ "vorsorge": "Prévention",
+ "illness": "Maladie"
},
"auth": {
"login": "Se connecter",
@@ -1632,17 +1609,7 @@
"copyToken": "Copier le jeton",
"mintedHint": "Copiez ce jeton maintenant — il n’est affiché qu’une seule fois."
},
- "edit": {
- "planOption": "Modifier le plan",
- "advancedOption": "Paramètres avancés"
- },
"advanced": {
- "title": "Paramètres avancés",
- "group": {
- "data": "Données",
- "lifecycle": "Cycle de vie",
- "externalApi": "API externe"
- },
"dataPortability": {
"import": {
"title": "Importer les prises",
@@ -2188,23 +2155,6 @@
"confidenceLow": "Confiance faible",
"pendingLabel": "Commentaire de tendance en cours de génération"
},
- "range": {
- "label": "Période",
- "noPriorData": "aucune donnée sur la période précédente",
- "option": {
- "7d": "7j",
- "30d": "30j",
- "90d": "90j",
- "1y": "1an"
- },
- "priorLabel": {
- "7d": "vs 7j précédents",
- "30d": "vs 30j précédents",
- "90d": "vs 90j précédents",
- "1y": "vs année précédente"
- },
- "noChange": "aucun changement"
- },
"correlationRow": {
"title": "Corrélations",
"disclaimer": "Les corrélations sont observationnelles, pas causales — parle à ton médecin si ta tension est élevée.",
@@ -2546,14 +2496,6 @@
"target": {
"adjustLink": "Ajuster la plage cible"
},
- "primary": {
- "avg30Label": "Moyenne sur 30 jours",
- "inRangeLabel": "Dans la cible",
- "inRangeAria": "{pct}% des jours enregistrés dans la cible"
- },
- "lastMeasurement": {
- "label": "Dernière mesure"
- },
"explainer": {
"trigger": "Qu'est-ce que {title} ?",
"bloodPressureTitle": "Tension artérielle",
@@ -2646,6 +2588,8 @@
"stairDescentSpeedBody": "C'est la vitesse à laquelle vous descendez une volée de marches. Avec la vitesse de montée, elle reflète le contrôle des jambes et l'équilibre : un rythme stable est bon signe, un rythme en baisse mérite attention.",
"breathingDisturbancesTitle": "Troubles respiratoires du sommeil",
"breathingDisturbancesBody": "Votre Apple Watch peut estimer à quelle fréquence votre respiration a été interrompue pendant le sommeil. Un indice plus bas est préférable ; un niveau qu'Apple signale comme élevé, surtout sur plusieurs nuits, mérite d'être évoqué avec un professionnel.",
+ "stepsTitle": "Pas",
+ "stepsBody": "Le nombre de pas correspond au total de pas que vous faites chaque jour. C'est la mesure la plus simple de l'activité quotidienne : un total journalier plus élevé reflète généralement un mode de vie plus actif, et une habitude régulière compte plus qu'une seule grosse journée.",
"readinessBody": "La forme du jour est une lecture quotidienne unique de la disposition de votre corps à affronter la journée, mêlant fréquence cardiaque au repos, variabilité de la fréquence cardiaque, sommeil et régularité de vos dernières nuits. Un score élevé signifie que votre organisme paraît récupéré et disponible ; un score plus bas est une invitation douce à lever le pied.",
"sleepScoreBody": "Votre score de sommeil résume toute une nuit en un seul chiffre — durée, efficacité, régularité des horaires et équilibre entre sommeil léger, profond et paradoxal. Observez la tendance sur plusieurs semaines plutôt que de courir après un score parfait sur une seule nuit.",
"recoveryBody": "La récupération reflète la qualité du rétablissement de votre corps, à partir de signaux nocturnes comme la variabilité de la fréquence cardiaque et la fréquence au repos. Un score élevé est un feu vert pour pousser ; un score plus bas suggère que votre organisme se reconstruit encore.",
@@ -2750,6 +2694,11 @@
"critical": "Critique"
},
"assessmentTitle": "Évaluation",
+ "restMode": {
+ "title": "Mode repos",
+ "body": "Un épisode de maladie ou d'affection est en cours, ceci reflète donc une période de récupération. Votre score n'en est pas pénalisé.",
+ "bodySince": "Un épisode de maladie ou d'affection est en cours depuis le {since}, ceci reflète donc une période de récupération. Votre score n'en est pas pénalisé."
+ },
"assessmentShowMore": "Afficher plus",
"assessmentShowLess": "Afficher moins",
"bloodPressureSectionTitle": "Tension artérielle",
@@ -3461,7 +3410,8 @@
"watchVital": "Hors plage : {vital}. Souvent ponctuel — à surveiller ces prochains jours.",
"firedHeadline": "Aujourd’hui, {count} de vos constantes sont hors de leur plage personnelle.",
"factors": "Facteurs possibles — jamais une cause : un entraînement intense, un mauvais sommeil, l’alcool, l’altitude, le stress ou une maladie. C’est un repère issu de vos propres références, pas un diagnostic.",
- "building": "Construction de vos références personnelles — enregistrez quelques jours de plus et cela apparaîtra."
+ "building": "Construction de vos références personnelles — enregistrez quelques jours de plus et cela apparaîtra.",
+ "illnessExplained": "Ces écarts reflètent probablement l’affection que vous avez consignée."
},
"trajectory": {
"cardTitle": "Projection à court terme",
@@ -3633,10 +3583,14 @@
"title": "Cœur et énergie du jour",
"subtitle": "Totaux cardiaques et énergétiques de la journée"
},
- "scoreLink": {
- "title": "Score de récupération",
- "subtitle": "Voyez comment se décompose le score nocturne",
- "cta": "Voir"
+ "block": {
+ "ansChargeExplainer": "À quel point votre système nerveux autonome est rechargé pour la journée — une mesure de recharge issue de vos signaux nocturnes de cœur et de respiration.",
+ "dayStrainExplainer": "La charge cardiovasculaire de la journée sur l'échelle d'effort de votre appareil. Lisez-la au regard de votre récupération, pas comme un objectif en soi.",
+ "workoutStrainExplainer": "À quel point une séance a été exigeante sur la même échelle d'effort — le coût cardiovasculaire de cette séance.",
+ "cardioLoadExplainer": "Une estimation de la charge d'entraînement qui suit la charge cardiovasculaire accumulée. Comparez-la à vos propres journées récentes.",
+ "averageHeartRateExplainer": "Votre fréquence cardiaque moyenne sur toute la journée ou le cycle, telle que rapportée par votre wearable.",
+ "maxHeartRateExplainer": "Votre fréquence cardiaque maximale sur toute la journée ou le cycle, telle que rapportée par votre wearable.",
+ "energyExpenditureKjExplainer": "L'énergie que votre corps a dépensée sur la journée, en kilojoules, telle que votre appareil la rapporte nativement."
}
},
"bodyTempDeviation": {
@@ -3871,18 +3825,7 @@
},
"integrations": {
"title": "Intégrations",
- "description": "Services connectés, canaux de diffusion et priorité des sources au même endroit.",
- "tabs": {
- "connections": "Connexions",
- "channels": "Canaux",
- "sources": "Sources"
- },
- "connections": {
- "description": "Connecter Withings, WHOOP, Fitbit, Polar, Oura et Nightscout."
- },
- "channels": {
- "description": "Telegram, ntfy, Web Push, Webhook et e-mail : les voies par lesquelles les notifications sont envoyées."
- }
+ "description": "Services connectés, canaux de diffusion et priorité des sources au même endroit."
},
"notifications": {
"title": "Notifications",
@@ -3892,7 +3835,7 @@
"inboxLink": "Ouvrir la boîte de réception"
},
"layout": {
- "title": "Disposition et personnalisation",
+ "title": "Disposition",
"description": "Organisez la disposition du tableau de bord et des analyses.",
"backToHub": "Retour à Disposition et personnalisation",
"dashboard": {
@@ -3902,14 +3845,6 @@
"insights": {
"title": "Analyses",
"description": "Ordre de la vue d'ensemble et des onglets de navigation."
- },
- "medications": {
- "title": "Médicaments",
- "description": "Vue de la liste et ordre des médicaments."
- },
- "mood": {
- "title": "Étiquettes d’humeur",
- "description": "Groupes, étiquettes et ordre du sélecteur d’humeur."
}
},
"dashboard": {
@@ -3996,12 +3931,6 @@
"description": "Téléchargez vos données en CSV ou en sauvegarde JSON complète, ou importez depuis Apple Santé, JSON ou CSV. Le compte rendu médical se trouve désormais dans Dossier de santé.",
"otherOptionsHeading": "Autres options d'export",
"downloadFailed": "Échec du téléchargement de l'export. Veuillez réessayer.",
- "hero": {
- "eyebrow": "Rendez-vous médical",
- "valueStatement": "Un rapport PDF imprimable pour votre rendez-vous médical — constantes, IMC, classification de tension, observance des traitements et (en option) historique de l'humeur, en quelques pages.",
- "cta": "Configurer & générer le rapport",
- "formatHint": "PDF · prêt à imprimer"
- },
"actions": {
"download": "Télécharger"
},
@@ -4010,10 +3939,6 @@
"until": "Jusqu’au"
},
"cards": {
- "doctorReport": {
- "title": "Rapport médical",
- "description": "PDF imprimable pour le rendez-vous médical — constantes, IMC, classification de la tension, observance des traitements, humeur."
- },
"measurementsCsv": {
"title": "Mesures",
"description": "Poids, tension artérielle, pouls, glycémie et toutes les autres mesures en valeurs séparées par des virgules."
@@ -4146,6 +4071,10 @@
"coach": {
"title": "Coach",
"description": "Activez le Coach, le ton et la portée, les souvenirs enregistrés."
+ },
+ "channels": {
+ "title": "Canaux",
+ "description": "Distribution via Telegram, ntfy, Web Push, Webhook et e-mail."
}
},
"profile": "Profil",
@@ -4387,14 +4316,6 @@
"clearedToast": "{count} informations enregistrées oubliées.",
"clearError": "Impossible d'effacer les informations enregistrées. Veuillez réessayer."
},
- "disableCoach": {
- "title": "Masquer le Coach",
- "description": "Masque le bouton et le panneau du Coach sur toutes les pages.",
- "toggleAria": "Masquer le bouton et le panneau du Coach",
- "savedHidden": "Coach masqué.",
- "savedShown": "Coach visible.",
- "saveError": "Impossible d'enregistrer la modification. Veuillez réessayer."
- },
"aboutMe": {
"title": "À propos de moi",
"description": "Contexte personnel que le Coach et le briefing quotidien lisent en plus de vos données.",
@@ -4857,7 +4778,17 @@
"rate_limited": "Trop de tentatives de connexion. Patientez un instant et réessayez.",
"generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion."
},
- "integrationSetupGuide": "Guide de configuration"
+ "integrationSetupGuide": "Guide de configuration",
+ "coach": {
+ "activate": {
+ "title": "Activer le Coach",
+ "description": "Affiche le bouton et le panneau du Coach. Activé par défaut — désactivez-le pour masquer le Coach partout.",
+ "toggleAria": "Activer le bouton et le panneau du Coach",
+ "savedOn": "Coach activé.",
+ "savedOff": "Coach masqué.",
+ "saveError": "Impossible d enregistrer la modification. Veuillez réessayer."
+ }
+ }
},
"admin": {
"title": "Administration",
@@ -5145,6 +5076,7 @@
"deleteAllConfirm": "Vraiment supprimer toutes les données ?",
"deleteAllConfirmDescription": "Cette action supprimera définitivement toutes les mesures, médicaments, événements de prise, jetons d'API, connexions Withings, défis d'authentification ouverts, canaux de notification (Telegram, ntfy, Web Push), abonnements push et suppressions de messages Telegram en file d'attente pour tous les utilisateurs. Les comptes utilisateurs, le journal d'audit et les retours soumis sont conservés afin que l'effacement reste traçable et que les rapports de bug restent triables.",
"deleteButton": "Supprimer toutes les données",
+ "deleteAllPromptLabel": "Tapez {token} pour continuer",
"finalDelete": "Supprimer définitivement",
"deletedResult": "Deleted: {measurements} measurements, {medications} Médicaments,{intakeEvents} intakes, {apiTokens} API tokens, {withingsConnections} Withings connections, {authChallenges} auth challenges, {notificationChannels} notification channels, {pushSubscriptions} push subscriptions, {telegramScheduledDeletions} queued Telegram deletions. Feedback rows preserved.",
"authRegister": "Inscription",
@@ -5273,17 +5205,9 @@
"title": "Intégrations",
"subtitle": "Configurez Umami, GlitchTip, Web Push et les rapports de bug."
},
- "ai-quality": {
- "title": "Qualité des analyses",
- "subtitle": "Scores d'utilité regroupés par gravité et fournisseur, sur les 30 derniers jours de retours utilisateurs."
- },
- "assistant": {
- "title": "Surfaces d'assistance",
- "subtitle": "Activer ou désactiver chaque surface pilotée par le modèle sans supprimer la configuration du fournisseur."
- },
- "coach-feedback": {
- "title": "Retours sur le Coach",
- "subtitle": "Taux d'utilité par version de prompt, ton et niveau de détail du Coach sur les 30 derniers jours."
+ "coach": {
+ "title": "Coach",
+ "subtitle": "Surfaces d'assistance, clé du serveur et les tableaux de qualité des retours du Coach et des analyses, au même endroit."
},
"feedback": {
"title": "Retours",
@@ -5875,11 +5799,6 @@
"sixDaysAgo": "il y a 6 jours"
}
},
- "relativeDay": {
- "today": "Aujourd'hui",
- "yesterday": "Hier",
- "daysAgo": "il y a {count} jours"
- },
"edit": {
"openLabel": "Modifier la plage cible de {metric}",
"title": "Modifier la plage cible — {metric}",
@@ -6344,7 +6263,9 @@
"delete": "Supprimer",
"temperatureSection": "Température et ovulation",
"intimacySection": "Intimité et contraception",
- "testsSection": "Tests"
+ "testsSection": "Tests",
+ "phaseDayHeader": "Jour {day} · {phase}",
+ "bbtHint": "Prends-la dès le matin, avant de te lever."
},
"settings": {
"title": "Réglages du cycle",
@@ -6420,6 +6341,24 @@
"ovulationEstimate": "Ovulation est.",
"tooltipTemp": "{temp} °C",
"ovulationConfirmed": "Ovulation confirmée"
+ },
+ "fieldInfo": {
+ "bbt": "Ta température au repos, prise dès le matin avant de te lever. Elle augmente légèrement (environ 0,2–0,3 °C) après l'ovulation.",
+ "bbtLabel": "Qu'est-ce que la température basale ?",
+ "mucus": "Les pertes vaginales évoluent au fil du cycle — de sèches ou collantes à crémeuses, puis aqueuses et comme du blanc d'œuf autour des jours les plus fertiles.",
+ "mucusLabel": "Qu'est-ce que la glaire cervicale ?",
+ "ovulationTest": "Un test urinaire à domicile qui détecte le pic de LH — la montée hormonale environ 24–36 h avant l'ovulation. Un test positif signale la période la plus fertile.",
+ "ovulationTestLabel": "Qu'est-ce qu'un test d'ovulation ?",
+ "spotting": "Léger saignement en dehors des règles.",
+ "spottingLabel": "Qu'est-ce que le spotting entre les règles ?",
+ "pregnancyTest": "Résultat d'un test de grossesse (hCG) à domicile.",
+ "pregnancyTestLabel": "Qu'est-ce qu'un test de grossesse ?",
+ "progesteroneTest": "Un test confirmant que l'ovulation a eu lieu — la progestérone augmente ensuite.",
+ "progesteroneTestLabel": "Qu'est-ce qu'un test de progestérone ?",
+ "intercourse": "Si un rapport sexuel a eu lieu ce jour-là.",
+ "intercourseLabel": "Que suit le rapport ?",
+ "protected": "Si une contraception a été utilisée pendant le rapport.",
+ "protectedLabel": "Que signifie protégé ?"
}
},
"cycleReminders": {
@@ -6478,7 +6417,19 @@
"BLOOD_GLUCOSE": "Glycémie",
"OXYGEN_SATURATION": "Saturation en oxygène",
"BODY_FAT": "Masse grasse",
- "BODY_TEMPERATURE": "Température corporelle"
+ "BODY_TEMPERATURE": "Température corporelle",
+ "FAT_MASS": "Masse grasse",
+ "FAT_FREE_MASS": "Masse maigre totale",
+ "MUSCLE_MASS": "Masse musculaire",
+ "LEAN_BODY_MASS": "Masse maigre",
+ "BONE_MASS": "Masse osseuse",
+ "TOTAL_BODY_WATER": "Eau corporelle",
+ "VISCERAL_FAT": "Graisse viscérale",
+ "BODY_MASS_INDEX": "Indice de masse corporelle (IMC)"
+ },
+ "typeGroups": {
+ "vitals": "Constantes vitales",
+ "bodyComposition": "Composition corporelle"
},
"empty": {
"title": "Aucun rappel pour l'instant",
@@ -6495,22 +6446,28 @@
"notifyHour": "Rappeler à",
"location": "Où (facultatif)",
"locationPlaceholder": "ex. Médecin, Laboratoire",
- "save": "Enregistrer"
+ "save": "Enregistrer",
+ "createTitle": "Nouveau rappel",
+ "editTitle": "Modifier le rappel"
},
"pushTitle": "Rappel de prévention",
"pushBody": "C'est l'heure : {label}",
- "pushLocation": "(chez {location})"
+ "pushLocation": "(chez {location})",
+ "edit": "Modifier",
+ "originCoach": "Coach",
+ "enabledToggleAria": "Activer ou désactiver ce rappel",
+ "until": "Jusqu'au {date}"
},
"labs": {
"title": "Résultats d'analyses",
"subtitle": "Analyses sanguines et biomarqueurs",
- "addResult": "Ajouter",
+ "addResult": "Ajouter une valeur",
"addDescription": "Enregistrez un biomarqueur à partir d'un compte rendu de laboratoire.",
"addFirst": "Ajouter votre premier résultat",
"emptyTitle": "Aucun résultat pour l'instant",
"emptyDescription": "Enregistrez des analyses comme l'HbA1c, le LDL ou la ferritine pour les suivre dans le temps et les partager avec votre médecin.",
"referenceLabel": "Référence",
- "readingsCount": "{count} valeurs",
+ "readingsCount": "{count} mesures",
"deleteLatest": "Supprimer la dernière valeur",
"deleteConfirmTitle": "Supprimer le résultat d'analyse ?",
"deleteConfirmDescription": "Ce résultat d'analyse sera définitivement supprimé.",
@@ -6539,12 +6496,130 @@
"referenceHint": "Reprenez l'intervalle de référence de votre compte rendu. Chaque borne est facultative.",
"note": "Note",
"notePlaceholder": "Contexte facultatif pour ce résultat",
- "requiredError": "Le biomarqueur, la valeur et l'unité sont obligatoires.",
+ "requiredError": "Saisissez une valeur.",
"rangeOrderError": "La référence basse ne doit pas dépasser la référence haute.",
"save": "Enregistrer",
"savedToast": "Résultat enregistré",
- "saveError": "Impossible d'enregistrer le résultat"
- }
+ "saveError": "Impossible d'enregistrer le résultat",
+ "biomarker": "Biomarqueur",
+ "biomarkerPlaceholder": "Choisir un biomarqueur",
+ "defineNew": "+ Définir un nouveau biomarqueur",
+ "noBiomarkersHint": "Définissez d'abord un biomarqueur pour enregistrer une valeur.",
+ "pickBiomarkerError": "Choisissez d'abord un biomarqueur."
+ },
+ "backToLabs": "Retour aux résultats",
+ "hasNote": "Note",
+ "editTitle": "Modifier la mesure",
+ "editDescription": "Corrigez la valeur, la date ou la note de cette mesure.",
+ "editedToast": "Mesure mise à jour",
+ "editError": "Impossible de mettre à jour la mesure",
+ "editTimestampError": "Saisissez une date valide.",
+ "editReading": "Modifier la mesure",
+ "deleteReading": "Supprimer la mesure",
+ "restoredToast": "Mesure restaurée",
+ "restoreError": "Impossible de restaurer la mesure",
+ "detail": {
+ "history": "Historique",
+ "emptyTitle": "Aucune mesure pour l'instant",
+ "emptyDescription": "Ajoutez une valeur pour suivre ce biomarqueur dans le temps."
+ },
+ "chart": {
+ "empty": "Aucune mesure sur cette période.",
+ "valueLabel": "Valeur",
+ "range": {
+ "30": "30 j",
+ "90": "90 j",
+ "365": "1 a",
+ "all": "Tout"
+ }
+ },
+ "biomarker": {
+ "manage": "Biomarqueurs",
+ "manageTitle": "Biomarqueurs",
+ "manageSheetDescription": "Définissez les marqueurs que vous suivez. Chacun est configuré une fois avec son unité et sa plage de référence.",
+ "managerDescription": "Définissez un marqueur une fois — son unité et sa plage sont réutilisées pour chaque mesure.",
+ "define": "Définir",
+ "defineFirst": "Définir votre premier biomarqueur",
+ "defineTitle": "Définir un biomarqueur",
+ "defineDescription": "Réglez le nom, l'unité et la plage une fois. Vous choisissez le marqueur à l'ajout d'une valeur.",
+ "editTitle": "Modifier le biomarqueur",
+ "edit": "Modifier",
+ "delete": "Supprimer",
+ "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.",
+ "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",
+ "noRange": "Pas de plage de référence",
+ "form": {
+ "seedLabel": "Partir d'un marqueur courant",
+ "seedPlaceholder": "Choisir un marqueur (facultatif)",
+ "seedHint": "En choisir un remplit les champs — ajustez la plage à votre laboratoire.",
+ "name": "Nom",
+ "namePlaceholder": "ex. Cholestérol LDL",
+ "unit": "Unité",
+ "unitPlaceholder": "ex. mg/dL",
+ "panel": "Panel",
+ "panelPlaceholder": "ex. Lipides",
+ "lowerBound": "Référence basse",
+ "upperBound": "Référence haute",
+ "lowerPlaceholder": "Borne inférieure",
+ "upperPlaceholder": "Borne supérieure",
+ "context": "Contexte",
+ "contextPlaceholder": "Note facultative sur ce marqueur",
+ "rangeHint": "Les deux bornes sont facultatives — laissez-en une vide pour une plage ouverte (ex. LDL < 116).",
+ "requiredError": "Le nom et l'unité sont requis.",
+ "rangeOrderError": "La borne inférieure ne doit pas dépasser la borne supérieure.",
+ "save": "Enregistrer le biomarqueur",
+ "savedToast": "Biomarqueur enregistré",
+ "updatedToast": "Biomarqueur mis à jour",
+ "saveError": "Impossible d'enregistrer le biomarqueur"
+ }
+ },
+ "catalog": {
+ "total-cholesterol": "Cholestérol total",
+ "ldl": "Cholestérol LDL",
+ "hdl": "Cholestérol HDL",
+ "triglycerides": "Triglycérides",
+ "fasting-glucose": "Glycémie à jeun",
+ "hba1c": "HbA1c",
+ "tsh": "TSH",
+ "ft3": "T3 libre",
+ "ft4": "T4 libre",
+ "ferritin": "Ferritine",
+ "transferrin-saturation": "Saturation de la transferrine",
+ "vitamin-d": "Vitamine D (25-OH)",
+ "vitamin-b12": "Vitamine B12",
+ "folate": "Folate",
+ "hs-crp": "CRP us",
+ "creatinine": "Créatinine",
+ "egfr": "DFGe",
+ "alt": "ALAT",
+ "ast": "ASAT",
+ "ggt": "GGT",
+ "sodium": "Sodium",
+ "potassium": "Potassium",
+ "hemoglobin": "Hémoglobine",
+ "hematocrit": "Hématocrite",
+ "wbc": "Leucocytes",
+ "platelets": "Plaquettes",
+ "panel": {
+ "lipids": "Lipides",
+ "glucose": "Métabolisme du glucose",
+ "thyroid": "Thyroïde",
+ "iron": "Fer",
+ "vitamins": "Vitamines",
+ "inflammation": "Inflammation",
+ "renal": "Rein",
+ "liver": "Foie",
+ "electrolytes": "Électrolytes",
+ "bloodCount": "Numération sanguine"
+ }
+ },
+ "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}"
},
"modules": {
"cycle": {
@@ -6584,7 +6659,7 @@
"description": "Chat IA fondé sur vos propres données."
},
"insights": {
- "label": "Analyses",
+ "label": "Insights",
"description": "Analyses IA, briefing et corrélations."
},
"doctorReport": {
@@ -6606,6 +6681,165 @@
"medications": {
"label": "Médicaments",
"description": "Schémas, prises et observance."
+ },
+ "illness": {
+ "label": "Journal de maladie",
+ "description": "Suivez les épisodes de maladie et d'affections avec un historique quotidien des symptômes."
+ }
+ },
+ "illness": {
+ "title": "Journal de maladie",
+ "subtitle": "Un suivi rétrospectif de vos épisodes de maladie et d'affections.",
+ "disclaimer": "Un journal personnel, pas un dispositif médical. Il ne diagnostique ni ne prédit.",
+ "newEpisode": "Nouvel épisode",
+ "logDay": "Enregistrer un jour",
+ "markRecovered": "Marquer comme rétabli",
+ "onsetOn": "Débuté le {date}",
+ "recoveredOn": "rétabli {date}",
+ "status": {
+ "active": "En cours",
+ "ongoing": "Persistant",
+ "recovered": "Rétabli"
+ },
+ "empty": {
+ "title": "Aucun épisode pour l'instant",
+ "body": "Démarrez un épisode pour tenir un historique quotidien des symptômes et revoir son évolution."
+ },
+ "type": {
+ "INFECTION": "Infection",
+ "ALLERGY": "Allergie",
+ "INJURY": "Blessure",
+ "MENTAL_HEALTH": "Santé mentale",
+ "AUTOIMMUNE": "Auto-immune",
+ "CHRONIC": "Chronique",
+ "OTHER": "Autre"
+ },
+ "lifecycle": {
+ "ACUTE": "Aiguë",
+ "CHRONIC_ONGOING": "Chronique / persistante",
+ "RECURRING": "Récurrente",
+ "FLARE": "Poussée"
+ },
+ "new": {
+ "title": "Nouvel épisode",
+ "description": "Enregistrez une maladie ou une affection. Vous pourrez ensuite noter les symptômes au quotidien.",
+ "label": "Nom",
+ "labelPlaceholder": "p. ex. Rhume",
+ "type": "Type",
+ "lifecycle": "Évolution",
+ "onset": "Débuté le",
+ "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"
+ },
+ "sheet": {
+ "title": "Enregistrer {date}",
+ "description": "Notez les symptômes du jour, votre forme et une éventuelle fièvre.",
+ "symptoms": "Symptômes",
+ "functionalImpact": "Votre forme",
+ "fever": "Température",
+ "note": "Note",
+ "noteSet": "Définie",
+ "notePlaceholder": "Comment s'est passée la journée (chiffré).",
+ "severityFor": "Intensité de {symptom}",
+ "saveError": "Impossible d'enregistrer la journée. Veuillez réessayer.",
+ "day": "Day"
+ },
+ "impact": {
+ "0": "Pleinement fonctionnel",
+ "1": "Légèrement limité",
+ "2": "Surtout au repos",
+ "3": "Alité"
+ },
+ "symptom": {
+ "runnyNose": "Nez qui coule",
+ "stuffyNose": "Nez bouché",
+ "sneezing": "Éternuements",
+ "soreThroat": "Mal de gorge",
+ "cough": "Toux",
+ "headache": "Mal de tête",
+ "bodyAches": "Courbatures",
+ "fatigue": "Fatigue"
+ },
+ "correlation": {
+ "title": "Comment cela s'est déroulé",
+ "learning": "Encore en apprentissage — enregistre quelques jours de plus pour voir des tendances ici.",
+ "preOnsetTitle": "Comment cela s'est annoncé",
+ "preOnsetBody": "Signaux qui se sont écartés de ta base avant le début que tu as noté.",
+ "nadirTitle": "Ce qui a changé",
+ "nadirBody": "À quel point chaque signal suivi s'est écarté de ta plage habituelle.",
+ "recoveryGapTitle": "Écart de récupération",
+ "recoveryGapLag": "Tes valeurs se sont stabilisées {days} jours après que tu te sois senti mieux.",
+ "recoveryGapLead": "Tes valeurs se sont stabilisées {days} jours avant que tu notes la guérison.",
+ "recoveryGapSame": "Tes valeurs se sont stabilisées le jour même où tu t'es senti mieux.",
+ "redFlagTitle": "À examiner de plus près",
+ "redFlagSpo2": "Ta saturation en oxygène est restée basse plusieurs jours. Si cela se reproduit, consulte un médecin.",
+ "redFlagFever": "Tu as eu une fièvre persistante. Si cela se reproduit, consulte un médecin.",
+ "direction": {
+ "above": "au-dessus de ton habituel",
+ "below": "en dessous de ton habituel"
+ }
+ },
+ "insights": {
+ "title": "Ton historique de maladies",
+ "countOne": "Tu as enregistré 1 épisode sur cette période.",
+ "countOther": "Tu as enregistré {count} épisodes sur cette période.",
+ "typicalGap": "Écart de récupération typique : {days} jours entre le mieux-être et la stabilisation de tes valeurs.",
+ "typicalGapLearning": "Encore quelques épisodes résolus et un écart de récupération typique apparaîtra ici.",
+ "byMonth": "Quand tu enregistres des épisodes",
+ "none": "Aucun épisode enregistré sur cette période."
+ },
+ "loadError": "Could not load this episode.",
+ "backToList": "Back to journal",
+ "detail": {
+ "note": "Note"
+ },
+ "edit": {
+ "title": "Edit episode",
+ "description": "Update this episode's details."
+ },
+ "menu": {
+ "label": "Episode actions"
+ },
+ "deleteConfirm": {
+ "title": "Delete this episode?",
+ "body": "“{label}” and all its day-logs will be removed. This cannot be undone."
+ },
+ "vital": {
+ "RESTING_HEART_RATE": "Resting heart rate",
+ "HEART_RATE_VARIABILITY": "Heart-rate variability",
+ "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",
+ "PULSE": "Pulse",
+ "WEIGHT": "Weight"
+ }
+ },
+ "coach": {
+ "reminderSuggestion": {
+ "title": "Créer un rappel ?",
+ "accept": "Créer le rappel",
+ "dismiss": "Pas maintenant",
+ "stop": "Je mesure assez",
+ "accepted": "Rappel créé",
+ "dismissed": "D'accord, je ne le proposerai plus",
+ "stopped": "Compris, j'arrête de proposer des rappels",
+ "failed": "Échec de la création, veuillez réessayer",
+ "cadence": {
+ "weightDaily": "Pesez-vous chaque jour, à peu près à la même heure",
+ "bp722": "Mesurez votre tension deux fois par jour pendant une semaine",
+ "glucoseStructured": "Mesurez votre glycémie selon un horaire quotidien structuré"
+ }
+ },
+ "settings": {
+ "reminderSuggestionsLabel": "Suggestions de fréquence",
+ "reminderSuggestionsDescription": "Laissez le Coach proposer de temps en temps un rappel fondé sur des preuves pour mesurer une donnée plus régulièrement."
}
}
}
diff --git a/messages/it.json b/messages/it.json
index 23305e7da..b7d55971b 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -123,37 +123,6 @@
"glucoseBelowTarget": "sotto obiettivo",
"glucoseAboveTarget": "sopra obiettivo",
"practiceLabel": "Studio",
- "dialog": {
- "title": "Referto medico (PDF)",
- "description": "Imposta il periodo di analisi e (opzionale) il nome dello studio — entrambi compaiono in copertina.",
- "startLabel": "Dal",
- "endLabel": "Al",
- "practiceLabel": "Studio / clinica (opzionale)",
- "practicePlaceholder": "es. Dr. Müller — Medicina generale",
- "preset90d": "Ultimi 90 giorni",
- "preset180d": "Ultimi 6 mesi",
- "preset365d": "Ultimi 12 mesi",
- "submit": "Genera PDF",
- "cancel": "Annulla",
- "errorEndBeforeStart": "La data di fine non può essere antecedente a quella di inizio.",
- "errorRangeTooLong": "Il periodo di analisi non può superare i 2 anni.",
- "errorInvalidDate": "Scegli una data di inizio e di fine valide."
- },
- "sections": {
- "title": "Seleziona contenuti",
- "bp": "Pressione arteriosa",
- "weight": "Peso",
- "pulse": "Pulsazioni a riposo",
- "bmi": "Indice di massa corporea (BMI)",
- "mood": "Umore",
- "moodSensitive": "Sensibile — disattivato per default.",
- "compliance": "Aderenza ai farmaci",
- "sleep": "Durata del sonno",
- "cycle": "Ciclo mestruale",
- "empty": "Nessun dato per il periodo selezionato. Il referto manterrà comunque la copertina.",
- "unavailableHint": "Nessun dato in questo periodo",
- "labs": "Risultati degli esami"
- },
"insurer": "Assicurazione sanitaria",
"insuranceNumber": "N. assicurato",
"summaryTitle": "Riepilogo clinico",
@@ -204,7 +173,14 @@
"eA1c": "A1C stimata",
"cv": "Variabilità (CV%)",
"spotCaveat": "Sono stime da misurazioni occasionali, non un profilo da monitor continuo — vanno lette come una tendenza, non come un verdetto clinico."
- }
+ },
+ "illnessTitle": "Condizioni e malattie",
+ "illnessOngoing": "In corso",
+ "illnessColCondition": "Condizione",
+ "illnessColType": "Tipo",
+ "illnessColLifecycle": "Decorso",
+ "illnessColOnset": "Inizio",
+ "illnessColResolved": "Risolta"
},
"errorBoundary": {
"title": "Qualcosa è andato storto",
@@ -266,7 +242,8 @@
"cycle": "Ciclo",
"labs": "Esami",
"recovery": "Recupero",
- "vorsorge": "Prevenzione"
+ "vorsorge": "Prevenzione",
+ "illness": "Malattia"
},
"auth": {
"login": "Accedi",
@@ -1632,17 +1609,7 @@
"copyToken": "Copia token",
"mintedHint": "Copia questo token ora — viene mostrato una sola volta."
},
- "edit": {
- "planOption": "Modifica piano",
- "advancedOption": "Impostazioni avanzate"
- },
"advanced": {
- "title": "Impostazioni avanzate",
- "group": {
- "data": "Dati",
- "lifecycle": "Ciclo di vita",
- "externalApi": "API esterna"
- },
"dataPortability": {
"import": {
"title": "Importa assunzioni",
@@ -2188,23 +2155,6 @@
"confidenceLow": "Confidenza bassa",
"pendingLabel": "Commento di trend in generazione"
},
- "range": {
- "label": "Periodo",
- "noPriorData": "nessun dato nel periodo precedente",
- "option": {
- "7d": "7g",
- "30d": "30g",
- "90d": "90g",
- "1y": "1a"
- },
- "priorLabel": {
- "7d": "vs 7g precedenti",
- "30d": "vs 30g precedenti",
- "90d": "vs 90g precedenti",
- "1y": "vs anno precedente"
- },
- "noChange": "nessuna variazione"
- },
"correlationRow": {
"title": "Correlazioni",
"disclaimer": "Le correlazioni sono osservazionali, non causali — parla con il medico se la pressione è alta.",
@@ -2546,14 +2496,6 @@
"target": {
"adjustLink": "Modifica l'intervallo obiettivo"
},
- "primary": {
- "avg30Label": "Media a 30 giorni",
- "inRangeLabel": "Nell'obiettivo",
- "inRangeAria": "{pct}% dei giorni registrati nell'obiettivo"
- },
- "lastMeasurement": {
- "label": "Ultima misurazione"
- },
"explainer": {
"trigger": "Che cos'è {title}?",
"bloodPressureTitle": "Pressione arteriosa",
@@ -2646,6 +2588,8 @@
"stairDescentSpeedBody": "È la velocità con cui scendi una rampa di scale. Insieme alla velocità in salita riflette il controllo delle gambe e l'equilibrio: un ritmo stabile è un buon segno, mentre uno in calo va tenuto d'occhio.",
"breathingDisturbancesTitle": "Disturbi respiratori nel sonno",
"breathingDisturbancesBody": "Il tuo Apple Watch può stimare quanto spesso la respirazione si è interrotta durante il sonno. Un indice più basso è migliore; un livello che Apple segnala come elevato, soprattutto su molte notti, andrebbe segnalato a un medico.",
+ "stepsTitle": "Passi",
+ "stepsBody": "Il conteggio dei passi è il numero totale di passi che fai ogni giorno. È la misura più semplice del movimento quotidiano: un conteggio giornaliero più alto riflette di solito uno stile di vita più attivo, e un'abitudine costante conta più di un singolo giorno intenso.",
"readinessBody": "La prontezza è una lettura giornaliera di quanto il tuo corpo sia pronto ad affrontare la giornata, combinando frequenza cardiaca a riposo, variabilità della frequenza cardiaca, sonno e regolarità delle ultime notti. Un punteggio più alto indica che il tuo organismo appare recuperato e pieno di risorse; uno più basso è un invito gentile a rallentare.",
"sleepScoreBody": "Il tuo punteggio del sonno riassume un'intera notte in un solo numero — durata, efficienza, regolarità degli orari ed equilibrio tra sonno leggero, profondo e REM. Osserva l'andamento nell'arco delle settimane invece di rincorrere un punteggio perfetto in una singola notte.",
"recoveryBody": "Il recupero riflette quanto bene il tuo corpo si è ripreso, basandosi su segnali notturni come la variabilità della frequenza cardiaca e la frequenza a riposo. Un punteggio alto è il via libera per spingere; uno più basso suggerisce che il tuo organismo si sta ancora ricostruendo.",
@@ -2750,6 +2694,11 @@
"critical": "Critico"
},
"assessmentTitle": "Valutazione",
+ "restMode": {
+ "title": "Modalità riposo",
+ "body": "È in corso un episodio di malattia o disturbo, quindi questo riflette una fase di recupero. Il tuo punteggio non viene abbassato per questo.",
+ "bodySince": "È in corso un episodio di malattia o disturbo dal {since}, quindi questo riflette una fase di recupero. Il tuo punteggio non viene abbassato per questo."
+ },
"assessmentShowMore": "Mostra altro",
"assessmentShowLess": "Mostra meno",
"bloodPressureSectionTitle": "Pressione arteriosa",
@@ -3461,7 +3410,8 @@
"watchVital": "Fuori intervallo: {vital}. Spesso è un episodio isolato — tienilo d’occhio nei prossimi giorni.",
"firedHeadline": "Oggi {count} dei tuoi parametri vitali sono fuori dal loro intervallo personale.",
"factors": "Possibili fattori — mai una causa: un allenamento intenso, poco sonno, alcol, altitudine, stress o una malattia. È un avviso basato sui tuoi parametri, non una diagnosi.",
- "building": "Stiamo costruendo i tuoi riferimenti personali — registra ancora qualche giorno e comparirà."
+ "building": "Stiamo costruendo i tuoi riferimenti personali — registra ancora qualche giorno e comparirà.",
+ "illnessExplained": "Questi scostamenti riflettono probabilmente la condizione che hai registrato."
},
"trajectory": {
"cardTitle": "Proiezione a breve termine",
@@ -3633,10 +3583,14 @@
"title": "Cuore ed energia del giorno",
"subtitle": "Totali cardiaci ed energetici dell'intera giornata"
},
- "scoreLink": {
- "title": "Punteggio di recupero",
- "subtitle": "Vedi come si scompone il punteggio notturno",
- "cta": "Apri"
+ "block": {
+ "ansChargeExplainer": "Quanto è carico il tuo sistema nervoso autonomo per la giornata — una lettura di ricarica ricavata dai segnali notturni di cuore e respiro.",
+ "dayStrainExplainer": "Il carico cardiovascolare della giornata sulla scala di sforzo del tuo dispositivo. Leggilo in rapporto al tuo recupero, non come obiettivo a sé.",
+ "workoutStrainExplainer": "Quanto è stato impegnativo un singolo allenamento sulla stessa scala di sforzo — il costo cardiovascolare di quella sessione.",
+ "cardioLoadExplainer": "Una stima del carico di allenamento che traccia il carico cardiovascolare accumulato. Confrontala con i tuoi giorni recenti.",
+ "averageHeartRateExplainer": "La tua frequenza cardiaca media nell'arco dell'intera giornata o ciclo, come riportata dal tuo wearable.",
+ "maxHeartRateExplainer": "La tua frequenza cardiaca massima nell'arco dell'intera giornata o ciclo, come riportata dal tuo wearable.",
+ "energyExpenditureKjExplainer": "L'energia che il tuo corpo ha consumato nella giornata, in kilojoule, come la riporta nativamente il tuo dispositivo."
}
},
"bodyTempDeviation": {
@@ -3871,18 +3825,7 @@
},
"integrations": {
"title": "Integrazioni",
- "description": "Servizi collegati, canali di consegna e priorità delle fonti in un unico posto.",
- "tabs": {
- "connections": "Connessioni",
- "channels": "Canali",
- "sources": "Origini"
- },
- "connections": {
- "description": "Collega Withings, WHOOP, Fitbit, Polar, Oura e Nightscout."
- },
- "channels": {
- "description": "Telegram, ntfy, Web Push, Webhook ed e-mail: le vie attraverso cui vengono recapitate le notifiche."
- }
+ "description": "Servizi collegati, canali di consegna e priorità delle fonti in un unico posto."
},
"notifications": {
"title": "Notifiche",
@@ -3892,7 +3835,7 @@
"inboxLink": "Apri la posta in arrivo"
},
"layout": {
- "title": "Layout e personalizzazione",
+ "title": "Layout",
"description": "Organizza la disposizione del cruscotto e degli approfondimenti.",
"backToHub": "Torna a Layout e personalizzazione",
"dashboard": {
@@ -3902,14 +3845,6 @@
"insights": {
"title": "Approfondimenti",
"description": "Ordine della panoramica e delle schede di navigazione."
- },
- "medications": {
- "title": "Farmaci",
- "description": "Vista elenco e ordine dei farmaci."
- },
- "mood": {
- "title": "Tag dell’umore",
- "description": "Gruppi, tag e ordine del selettore dell’umore."
}
},
"dashboard": {
@@ -3996,12 +3931,6 @@
"description": "Scarica i tuoi dati come CSV o backup JSON completo, oppure importa da Apple Salute, JSON o CSV. Il referto medico ora si trova in Cartella sanitaria.",
"otherOptionsHeading": "Altre opzioni di esportazione",
"downloadFailed": "Impossibile scaricare l'esportazione. Riprova.",
- "hero": {
- "eyebrow": "Visita medica",
- "valueStatement": "Un referto PDF stampabile per la visita medica — parametri vitali, BMI, classificazione della pressione, aderenza alla terapia e (facoltativamente) l'andamento dell'umore, in poche pagine.",
- "cta": "Configura e genera il referto",
- "formatHint": "PDF · pronto per la stampa"
- },
"actions": {
"download": "Scarica"
},
@@ -4010,10 +3939,6 @@
"until": "Fino al"
},
"cards": {
- "doctorReport": {
- "title": "Referto medico",
- "description": "PDF stampabile per la visita medica — parametri vitali, IMC, classificazione della pressione, aderenza ai farmaci, umore."
- },
"measurementsCsv": {
"title": "Misurazioni",
"description": "Peso, pressione, polso, glicemia e tutte le altre misurazioni in valori separati da virgola."
@@ -4146,6 +4071,10 @@
"coach": {
"title": "Coach",
"description": "Attiva il Coach, tono e ambito, ricordi salvati."
+ },
+ "channels": {
+ "title": "Canali",
+ "description": "Recapito tramite Telegram, ntfy, Web Push, Webhook ed e-mail."
}
},
"profile": "Profilo",
@@ -4387,14 +4316,6 @@
"clearedToast": "Dimenticate {count} informazioni memorizzate.",
"clearError": "Impossibile cancellare le informazioni memorizzate. Riprova."
},
- "disableCoach": {
- "title": "Nascondi il Coach",
- "description": "Nasconde il pulsante e il pannello del Coach su tutte le pagine.",
- "toggleAria": "Nascondi il pulsante e il pannello del Coach",
- "savedHidden": "Coach nascosto.",
- "savedShown": "Coach visibile.",
- "saveError": "Impossibile salvare la modifica. Riprova."
- },
"aboutMe": {
"title": "Su di me",
"description": "Contesto personale che il Coach e il briefing giornaliero leggono insieme ai tuoi dati.",
@@ -4857,7 +4778,17 @@
"rate_limited": "Troppi tentativi di connessione. Attendi un momento e riprova.",
"generic": "Errore sconosciuto durante la connessione. Riavvia la connessione."
},
- "integrationSetupGuide": "Guida alla configurazione"
+ "integrationSetupGuide": "Guida alla configurazione",
+ "coach": {
+ "activate": {
+ "title": "Attiva il Coach",
+ "description": "Mostra il pulsante e il pannello del Coach. Attivo per impostazione predefinita: disattivalo per nascondere il Coach ovunque.",
+ "toggleAria": "Attiva il pulsante e il pannello del Coach",
+ "savedOn": "Coach attivato.",
+ "savedOff": "Coach nascosto.",
+ "saveError": "Impossibile salvare la modifica. Riprova."
+ }
+ }
},
"admin": {
"title": "Administration",
@@ -5145,6 +5076,7 @@
"deleteAllConfirm": "Eliminare davvero tutti i dati?",
"deleteAllConfirmDescription": "Questa operazione eliminerà definitivamente tutte le misurazioni, i farmaci, gli eventi di assunzione, i token API, le connessioni Withings, le challenge di autenticazione aperte, i canali di notifica (Telegram, ntfy, Web Push), le sottoscrizioni push e le eliminazioni di messaggi Telegram in coda di tutti gli utenti. Gli account utente, il log di audit e i feedback inviati vengono conservati affinché la cancellazione resti tracciabile e le segnalazioni di bug restino gestibili.",
"deleteButton": "Elimina tutti i dati",
+ "deleteAllPromptLabel": "Digita {token} per continuare",
"finalDelete": "Elimina definitivamente",
"deletedResult": "Deleted: {measurements} measurements, {medications} Farmaci,{intakeEvents} intakes, {apiTokens} API tokens, {withingsConnections} Withings connections, {authChallenges} auth challenges, {notificationChannels} notification channels, {pushSubscriptions} push subscriptions, {telegramScheduledDeletions} queued Telegram deletions. Feedback rows preserved.",
"authRegister": "Registrazione",
@@ -5273,17 +5205,9 @@
"title": "Integrazioni",
"subtitle": "Configura Umami, GlitchTip, Web Push e le segnalazioni di bug."
},
- "ai-quality": {
- "title": "Qualità delle analisi",
- "subtitle": "Punteggi di utilità raggruppati per gravità e provider, dagli ultimi 30 giorni di feedback degli utenti."
- },
- "assistant": {
- "title": "Superfici dell'assistente",
- "subtitle": "Attiva o disattiva ciascuna superficie guidata dal modello senza rimuovere la configurazione del provider."
- },
- "coach-feedback": {
- "title": "Feedback del Coach",
- "subtitle": "Tasso di utilità per versione del prompt, tono e livello di dettaglio del Coach degli ultimi 30 giorni."
+ "coach": {
+ "title": "Coach",
+ "subtitle": "Superfici dell'assistente, chiave del server e le tabelle di qualità del feedback di Coach e analisi, in un unico posto."
},
"feedback": {
"title": "Feedback",
@@ -5875,11 +5799,6 @@
"sixDaysAgo": "6 giorni fa"
}
},
- "relativeDay": {
- "today": "Oggi",
- "yesterday": "Ieri",
- "daysAgo": "{count} giorni fa"
- },
"edit": {
"openLabel": "Modifica il range obiettivo per {metric}",
"title": "Modifica range obiettivo — {metric}",
@@ -6344,7 +6263,9 @@
"delete": "Elimina",
"temperatureSection": "Temperatura e ovulazione",
"intimacySection": "Intimità e contraccezione",
- "testsSection": "Test"
+ "testsSection": "Test",
+ "phaseDayHeader": "Giorno {day} · {phase}",
+ "bbtHint": "Misurala appena sveglia, prima di alzarti."
},
"settings": {
"title": "Impostazioni del ciclo",
@@ -6420,6 +6341,24 @@
"ovulationEstimate": "Ovulazione stim.",
"tooltipTemp": "{temp} °C",
"ovulationConfirmed": "Ovulazione confermata"
+ },
+ "fieldInfo": {
+ "bbt": "La tua temperatura a riposo, misurata appena sveglia prima di alzarti. Dopo l'ovulazione sale leggermente (circa 0,2–0,3 °C).",
+ "bbtLabel": "Cos'è la temperatura basale?",
+ "mucus": "Le perdite vaginali cambiano durante il ciclo — da secche o appiccicose a cremose, poi acquose e simili ad albume nei giorni più fertili.",
+ "mucusLabel": "Cos'è il muco cervicale?",
+ "ovulationTest": "Un test delle urine domestico che rileva il picco di LH — l'aumento ormonale circa 24–36 h prima dell'ovulazione. Un positivo segnala la finestra più fertile.",
+ "ovulationTestLabel": "Cos'è un test di ovulazione?",
+ "spotting": "Lieve sanguinamento al di fuori delle mestruazioni.",
+ "spottingLabel": "Cos'è lo spotting tra le mestruazioni?",
+ "pregnancyTest": "Risultato di un test di gravidanza (hCG) domestico.",
+ "pregnancyTestLabel": "Cos'è un test di gravidanza?",
+ "progesteroneTest": "Un test che conferma l'avvenuta ovulazione — il progesterone sale dopo.",
+ "progesteroneTestLabel": "Cos'è un test del progesterone?",
+ "intercourse": "Se in questo giorno ci sono stati rapporti sessuali.",
+ "intercourseLabel": "Cosa registra i rapporti?",
+ "protected": "Se durante il rapporto è stata usata la contraccezione.",
+ "protectedLabel": "Cosa significa protetto?"
}
},
"cycleReminders": {
@@ -6478,7 +6417,19 @@
"BLOOD_GLUCOSE": "Glicemia",
"OXYGEN_SATURATION": "Saturazione di ossigeno",
"BODY_FAT": "Grasso corporeo",
- "BODY_TEMPERATURE": "Temperatura corporea"
+ "BODY_TEMPERATURE": "Temperatura corporea",
+ "FAT_MASS": "Massa grassa",
+ "FAT_FREE_MASS": "Massa magra totale",
+ "MUSCLE_MASS": "Massa muscolare",
+ "LEAN_BODY_MASS": "Massa magra",
+ "BONE_MASS": "Massa ossea",
+ "TOTAL_BODY_WATER": "Acqua corporea",
+ "VISCERAL_FAT": "Grasso viscerale",
+ "BODY_MASS_INDEX": "Indice di massa corporea (IMC)"
+ },
+ "typeGroups": {
+ "vitals": "Parametri vitali",
+ "bodyComposition": "Composizione corporea"
},
"empty": {
"title": "Ancora nessun promemoria",
@@ -6495,22 +6446,28 @@
"notifyHour": "Ricorda alle",
"location": "Dove (facoltativo)",
"locationPlaceholder": "es. Medico, Laboratorio",
- "save": "Salva"
+ "save": "Salva",
+ "createTitle": "Nuovo promemoria",
+ "editTitle": "Modifica promemoria"
},
"pushTitle": "Promemoria di prevenzione",
"pushBody": "È ora di: {label}",
- "pushLocation": "(presso {location})"
+ "pushLocation": "(presso {location})",
+ "edit": "Modifica",
+ "originCoach": "Coach",
+ "enabledToggleAria": "Attiva o disattiva questo promemoria",
+ "until": "Fino al {date}"
},
"labs": {
"title": "Risultati degli esami",
"subtitle": "Analisi del sangue e biomarcatori",
- "addResult": "Aggiungi",
+ "addResult": "Aggiungi valore",
"addDescription": "Registra un biomarcatore da un referto di laboratorio.",
"addFirst": "Aggiungi il primo risultato",
"emptyTitle": "Ancora nessun risultato",
"emptyDescription": "Registra esami come HbA1c, LDL o ferritina per seguirli nel tempo e condividerli con il tuo medico.",
"referenceLabel": "Riferimento",
- "readingsCount": "{count} valori",
+ "readingsCount": "{count} misurazioni",
"deleteLatest": "Elimina l'ultimo valore",
"deleteConfirmTitle": "Eliminare il risultato di laboratorio?",
"deleteConfirmDescription": "Questo risultato di laboratorio verrà eliminato definitivamente.",
@@ -6539,12 +6496,130 @@
"referenceHint": "Copia l'intervallo di riferimento dal referto. Ciascun limite è facoltativo.",
"note": "Nota",
"notePlaceholder": "Contesto facoltativo per questo risultato",
- "requiredError": "Biomarcatore, valore e unità sono obbligatori.",
+ "requiredError": "Inserisci un valore.",
"rangeOrderError": "Il riferimento minimo non può superare quello massimo.",
"save": "Salva",
"savedToast": "Risultato salvato",
- "saveError": "Impossibile salvare il risultato"
- }
+ "saveError": "Impossibile salvare il risultato",
+ "biomarker": "Biomarcatore",
+ "biomarkerPlaceholder": "Scegli un biomarcatore",
+ "defineNew": "+ Definisci nuovo biomarcatore",
+ "noBiomarkersHint": "Definisci prima un biomarcatore per registrare un valore.",
+ "pickBiomarkerError": "Scegli prima un biomarcatore."
+ },
+ "backToLabs": "Torna ai risultati",
+ "hasNote": "Nota",
+ "editTitle": "Modifica misurazione",
+ "editDescription": "Correggi valore, data o nota di questa misurazione.",
+ "editedToast": "Misurazione aggiornata",
+ "editError": "Impossibile aggiornare la misurazione",
+ "editTimestampError": "Inserisci una data valida.",
+ "editReading": "Modifica misurazione",
+ "deleteReading": "Elimina misurazione",
+ "restoredToast": "Misurazione ripristinata",
+ "restoreError": "Impossibile ripristinare la misurazione",
+ "detail": {
+ "history": "Cronologia",
+ "emptyTitle": "Ancora nessuna misurazione",
+ "emptyDescription": "Aggiungi un valore per seguire questo biomarcatore nel tempo."
+ },
+ "chart": {
+ "empty": "Nessuna misurazione in questo periodo.",
+ "valueLabel": "Valore",
+ "range": {
+ "30": "30 g",
+ "90": "90 g",
+ "365": "1 a",
+ "all": "Tutto"
+ }
+ },
+ "biomarker": {
+ "manage": "Biomarcatori",
+ "manageTitle": "Biomarcatori",
+ "manageSheetDescription": "Definisci i marcatori che segui. Ognuno si imposta una volta con la sua unità e l'intervallo di riferimento.",
+ "managerDescription": "Definisci un marcatore una volta — unità e intervallo valgono per ogni misurazione.",
+ "define": "Definisci",
+ "defineFirst": "Definisci il tuo primo biomarcatore",
+ "defineTitle": "Definisci biomarcatore",
+ "defineDescription": "Imposta nome, unità e intervallo una volta. Scegli il marcatore quando aggiungi un valore.",
+ "editTitle": "Modifica biomarcatore",
+ "edit": "Modifica",
+ "delete": "Elimina",
+ "deletedToast": "Biomarcatore eliminato",
+ "deleteError": "Impossibile eliminare il biomarcatore",
+ "deleteConfirmTitle": "Eliminare il biomarcatore?",
+ "deleteConfirmDescription": "La definizione viene rimossa. Le misurazioni esistenti restano ma vengono scollegate.",
+ "emptyTitle": "Ancora nessun biomarcatore",
+ "emptyDescription": "Definisci marcatori come LDL o HbA1c per registrare i valori.",
+ "loadError": "Impossibile caricare i biomarcatori",
+ "noRange": "Nessun intervallo di riferimento",
+ "form": {
+ "seedLabel": "Parti da un marcatore comune",
+ "seedPlaceholder": "Scegli un marcatore (facoltativo)",
+ "seedHint": "Sceglierne uno compila i campi — adatta l'intervallo al tuo laboratorio.",
+ "name": "Nome",
+ "namePlaceholder": "es. Colesterolo LDL",
+ "unit": "Unità",
+ "unitPlaceholder": "es. mg/dL",
+ "panel": "Panel",
+ "panelPlaceholder": "es. Lipidi",
+ "lowerBound": "Riferimento minimo",
+ "upperBound": "Riferimento massimo",
+ "lowerPlaceholder": "Limite inferiore",
+ "upperPlaceholder": "Limite superiore",
+ "context": "Contesto",
+ "contextPlaceholder": "Nota facoltativa su questo marcatore",
+ "rangeHint": "Entrambi i limiti sono facoltativi — lasciane uno vuoto per un intervallo aperto (es. LDL < 116).",
+ "requiredError": "Nome e unità sono obbligatori.",
+ "rangeOrderError": "Il limite inferiore non deve superare quello superiore.",
+ "save": "Salva biomarcatore",
+ "savedToast": "Biomarcatore salvato",
+ "updatedToast": "Biomarcatore aggiornato",
+ "saveError": "Impossibile salvare il biomarcatore"
+ }
+ },
+ "catalog": {
+ "total-cholesterol": "Colesterolo totale",
+ "ldl": "Colesterolo LDL",
+ "hdl": "Colesterolo HDL",
+ "triglycerides": "Trigliceridi",
+ "fasting-glucose": "Glicemia a digiuno",
+ "hba1c": "HbA1c",
+ "tsh": "TSH",
+ "ft3": "T3 libero",
+ "ft4": "T4 libero",
+ "ferritin": "Ferritina",
+ "transferrin-saturation": "Saturazione della transferrina",
+ "vitamin-d": "Vitamina D (25-OH)",
+ "vitamin-b12": "Vitamina B12",
+ "folate": "Folati",
+ "hs-crp": "PCR ad alta sensibilità",
+ "creatinine": "Creatinina",
+ "egfr": "eGFR",
+ "alt": "ALT",
+ "ast": "AST",
+ "ggt": "GGT",
+ "sodium": "Sodio",
+ "potassium": "Potassio",
+ "hemoglobin": "Emoglobina",
+ "hematocrit": "Ematocrito",
+ "wbc": "Leucociti",
+ "platelets": "Piastrine",
+ "panel": {
+ "lipids": "Lipidi",
+ "glucose": "Metabolismo del glucosio",
+ "thyroid": "Tiroide",
+ "iron": "Ferro",
+ "vitamins": "Vitamine",
+ "inflammation": "Infiammazione",
+ "renal": "Reni",
+ "liver": "Fegato",
+ "electrolytes": "Elettroliti",
+ "bloodCount": "Emocromo"
+ }
+ },
+ "noteLoadFailed": "Impossibile caricare la nota; resterà invariata al salvataggio.",
+ "showingLatestOf": "Visualizzazione degli ultimi {shown} risultati su {total}"
},
"modules": {
"cycle": {
@@ -6584,7 +6659,7 @@
"description": "Chat IA basata sui tuoi dati."
},
"insights": {
- "label": "Analisi",
+ "label": "Insights",
"description": "Analisi IA, briefing e correlazioni."
},
"doctorReport": {
@@ -6606,6 +6681,165 @@
"medications": {
"label": "Farmaci",
"description": "Schemi, assunzioni e aderenza."
+ },
+ "illness": {
+ "label": "Diario delle malattie",
+ "description": "Registra episodi di malattia e disturbi con una cronologia giornaliera dei sintomi."
+ }
+ },
+ "illness": {
+ "title": "Diario delle malattie",
+ "subtitle": "Un registro retrospettivo dei tuoi episodi di malattia e disturbi.",
+ "disclaimer": "Un diario personale, non un dispositivo medico. Non diagnostica né prevede.",
+ "newEpisode": "Nuovo episodio",
+ "logDay": "Registra un giorno",
+ "markRecovered": "Segna come guarito",
+ "onsetOn": "Iniziato il {date}",
+ "recoveredOn": "guarito {date}",
+ "status": {
+ "active": "Attivo",
+ "ongoing": "In corso",
+ "recovered": "Guarito"
+ },
+ "empty": {
+ "title": "Ancora nessun episodio",
+ "body": "Avvia un episodio per tenere una cronologia giornaliera dei sintomi e rivedere come si è evoluto."
+ },
+ "type": {
+ "INFECTION": "Infezione",
+ "ALLERGY": "Allergia",
+ "INJURY": "Infortunio",
+ "MENTAL_HEALTH": "Salute mentale",
+ "AUTOIMMUNE": "Autoimmune",
+ "CHRONIC": "Cronica",
+ "OTHER": "Altro"
+ },
+ "lifecycle": {
+ "ACUTE": "Acuta",
+ "CHRONIC_ONGOING": "Cronica / in corso",
+ "RECURRING": "Ricorrente",
+ "FLARE": "Riacutizzazione"
+ },
+ "new": {
+ "title": "Nuovo episodio",
+ "description": "Registra una malattia o un disturbo. Potrai annotare i sintomi giornalieri in seguito.",
+ "label": "Nome",
+ "labelPlaceholder": "es. Raffreddore",
+ "type": "Tipo",
+ "lifecycle": "Decorso",
+ "onset": "Iniziato il",
+ "note": "Nota",
+ "notePlaceholder": "Qualcosa da ricordare (cifrato).",
+ "saveError": "Impossibile salvare l'episodio. Riprova.",
+ "parent": "Part of",
+ "parentNone": "Not linked"
+ },
+ "sheet": {
+ "title": "Registra {date}",
+ "description": "Annota i sintomi di oggi, come funzioni e l'eventuale febbre.",
+ "symptoms": "Sintomi",
+ "functionalImpact": "Come funzioni",
+ "fever": "Temperatura",
+ "note": "Nota",
+ "noteSet": "Impostata",
+ "notePlaceholder": "Com'è andata la giornata (cifrato).",
+ "severityFor": "Intensità di {symptom}",
+ "saveError": "Impossibile salvare la giornata. Riprova.",
+ "day": "Day"
+ },
+ "impact": {
+ "0": "Pienamente funzionale",
+ "1": "Leggermente limitato",
+ "2": "Per lo più a riposo",
+ "3": "A letto"
+ },
+ "symptom": {
+ "runnyNose": "Naso che cola",
+ "stuffyNose": "Naso chiuso",
+ "sneezing": "Starnuti",
+ "soreThroat": "Mal di gola",
+ "cough": "Tosse",
+ "headache": "Mal di testa",
+ "bodyAches": "Dolori muscolari",
+ "fatigue": "Affaticamento"
+ },
+ "correlation": {
+ "title": "Come si è svolto",
+ "learning": "Ancora in apprendimento: registra qualche giorno in più per vedere i pattern qui.",
+ "preOnsetTitle": "Come si è annunciato",
+ "preOnsetBody": "Segnali che si sono allontanati dalla tua base prima dell'inizio che hai segnato.",
+ "nadirTitle": "Cosa è cambiato",
+ "nadirBody": "Quanto ogni segnale registrato si è allontanato dal tuo intervallo abituale.",
+ "recoveryGapTitle": "Divario di recupero",
+ "recoveryGapLag": "I tuoi valori si sono stabilizzati {days} giorni dopo esserti sentito meglio.",
+ "recoveryGapLead": "I tuoi valori si sono stabilizzati {days} giorni prima di segnare la guarigione.",
+ "recoveryGapSame": "I tuoi valori si sono stabilizzati lo stesso giorno in cui ti sei sentito meglio.",
+ "redFlagTitle": "Da guardare più da vicino",
+ "redFlagSpo2": "La tua saturazione di ossigeno è rimasta bassa per diversi giorni. Se si ripete, rivolgiti a un medico.",
+ "redFlagFever": "Hai avuto febbre persistente. Se si ripete, rivolgiti a un medico.",
+ "direction": {
+ "above": "sopra il tuo solito",
+ "below": "sotto il tuo solito"
+ }
+ },
+ "insights": {
+ "title": "La tua storia di malattie",
+ "countOne": "Hai registrato 1 episodio in questo periodo.",
+ "countOther": "Hai registrato {count} episodi in questo periodo.",
+ "typicalGap": "Divario di recupero tipico: {days} giorni tra il sentirti meglio e la stabilizzazione dei valori.",
+ "typicalGapLearning": "Ancora qualche episodio risolto e qui apparirà un divario di recupero tipico.",
+ "byMonth": "Quando tendi a registrare episodi",
+ "none": "Nessun episodio registrato in questo periodo."
+ },
+ "loadError": "Could not load this episode.",
+ "backToList": "Back to journal",
+ "detail": {
+ "note": "Note"
+ },
+ "edit": {
+ "title": "Edit episode",
+ "description": "Update this episode's details."
+ },
+ "menu": {
+ "label": "Episode actions"
+ },
+ "deleteConfirm": {
+ "title": "Delete this episode?",
+ "body": "“{label}” and all its day-logs will be removed. This cannot be undone."
+ },
+ "vital": {
+ "RESTING_HEART_RATE": "Resting heart rate",
+ "HEART_RATE_VARIABILITY": "Heart-rate variability",
+ "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",
+ "PULSE": "Pulse",
+ "WEIGHT": "Weight"
+ }
+ },
+ "coach": {
+ "reminderSuggestion": {
+ "title": "Impostare un promemoria?",
+ "accept": "Imposta promemoria",
+ "dismiss": "Non ora",
+ "stop": "Misuro abbastanza",
+ "accepted": "Promemoria impostato",
+ "dismissed": "Va bene, non lo suggerirò più",
+ "stopped": "Capito, smetto di suggerire promemoria",
+ "failed": "Impossibile impostarlo, riprova",
+ "cadence": {
+ "weightDaily": "Pesati ogni giorno, più o meno alla stessa ora",
+ "bp722": "Misura la pressione due volte al giorno per una settimana",
+ "glucoseStructured": "Misura la glicemia con un programma giornaliero strutturato"
+ }
+ },
+ "settings": {
+ "reminderSuggestionsLabel": "Suggerimenti di cadenza",
+ "reminderSuggestionsDescription": "Lascia che il Coach proponga ogni tanto un promemoria basato sull'evidenza per misurare una metrica più regolarmente."
}
}
}
diff --git a/messages/pl.json b/messages/pl.json
index 8e1ff3360..20bda86fc 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -123,37 +123,6 @@
"glucoseBelowTarget": "poniżej celu",
"glucoseAboveTarget": "powyżej celu",
"practiceLabel": "Gabinet",
- "dialog": {
- "title": "Raport medyczny (PDF)",
- "description": "Ustaw okres analizy i (opcjonalnie) nazwę gabinetu — oba pojawią się na stronie tytułowej.",
- "startLabel": "Od",
- "endLabel": "Do",
- "practiceLabel": "Gabinet / klinika (opcjonalnie)",
- "practicePlaceholder": "np. dr Müller — lekarz rodzinny",
- "preset90d": "Ostatnie 90 dni",
- "preset180d": "Ostatnie 6 miesięcy",
- "preset365d": "Ostatnie 12 miesięcy",
- "submit": "Wygeneruj PDF",
- "cancel": "Anuluj",
- "errorEndBeforeStart": "Data końcowa nie może być wcześniejsza niż początkowa.",
- "errorRangeTooLong": "Okres analizy nie może przekraczać 2 lat.",
- "errorInvalidDate": "Wybierz poprawną datę początku i końca."
- },
- "sections": {
- "title": "Wybierz zawartość",
- "bp": "Ciśnienie krwi",
- "weight": "Waga",
- "pulse": "Tętno spoczynkowe",
- "bmi": "Wskaźnik masy ciała (BMI)",
- "mood": "Nastrój",
- "moodSensitive": "Wrażliwe — domyślnie wyłączone.",
- "compliance": "Przestrzeganie leków",
- "sleep": "Czas snu",
- "cycle": "Cykl menstruacyjny",
- "empty": "Brak danych dla wybranego okresu. Raport zachowa stronę tytułową.",
- "unavailableHint": "Brak danych w tym okresie",
- "labs": "Wyniki badań"
- },
"insurer": "Ubezpieczyciel zdrowotny",
"insuranceNumber": "Nr ubezpieczonego",
"summaryTitle": "Podsumowanie kliniczne",
@@ -204,7 +173,14 @@
"eA1c": "Szacowane HbA1c",
"cv": "Zmienność (CV%)",
"spotCaveat": "To szacunki z pojedynczych pomiarów, nie profil z monitora ciągłego — czytaj je jako kierunek zmian, a nie kliniczny werdykt."
- }
+ },
+ "illnessTitle": "Schorzenia i choroby",
+ "illnessOngoing": "Trwająca",
+ "illnessColCondition": "Schorzenie",
+ "illnessColType": "Typ",
+ "illnessColLifecycle": "Przebieg",
+ "illnessColOnset": "Początek",
+ "illnessColResolved": "Ustąpiła"
},
"errorBoundary": {
"title": "Coś poszło nie tak",
@@ -266,7 +242,8 @@
"cycle": "Cykl",
"labs": "Wyniki badań",
"recovery": "Regeneracja",
- "vorsorge": "Profilaktyka"
+ "vorsorge": "Profilaktyka",
+ "illness": "Choroba"
},
"auth": {
"login": "Zaloguj się",
@@ -1632,17 +1609,7 @@
"copyToken": "Kopiuj token",
"mintedHint": "Skopiuj ten token teraz — jest pokazywany tylko raz."
},
- "edit": {
- "planOption": "Edytuj plan",
- "advancedOption": "Ustawienia zaawansowane"
- },
"advanced": {
- "title": "Ustawienia zaawansowane",
- "group": {
- "data": "Dane",
- "lifecycle": "Cykl życia",
- "externalApi": "Zewnętrzne API"
- },
"dataPortability": {
"import": {
"title": "Importuj przyjęcia",
@@ -2188,23 +2155,6 @@
"confidenceLow": "Niska pewność",
"pendingLabel": "Komentarz trendu jest generowany"
},
- "range": {
- "label": "Okres",
- "noPriorData": "brak danych z poprzedniego okresu",
- "option": {
- "7d": "7d",
- "30d": "30d",
- "90d": "90d",
- "1y": "1r"
- },
- "priorLabel": {
- "7d": "vs poprzednie 7d",
- "30d": "vs poprzednie 30d",
- "90d": "vs poprzednie 90d",
- "1y": "vs poprzedni rok"
- },
- "noChange": "bez zmian"
- },
"correlationRow": {
"title": "Korelacje",
"disclaimer": "Korelacje są obserwacyjne, nie przyczynowe — porozmawiaj z lekarzem, jeśli ciśnienie jest wysokie.",
@@ -2546,14 +2496,6 @@
"target": {
"adjustLink": "Dostosuj zakres docelowy"
},
- "primary": {
- "avg30Label": "Średnia z 30 dni",
- "inRangeLabel": "W zakresie",
- "inRangeAria": "{pct}% zarejestrowanych dni w zakresie celu"
- },
- "lastMeasurement": {
- "label": "Ostatni pomiar"
- },
"explainer": {
"trigger": "Czym jest {title}?",
"bloodPressureTitle": "Ciśnienie krwi",
@@ -2646,6 +2588,8 @@
"stairDescentSpeedBody": "To prędkość, z jaką schodzisz bieg schodów. Razem z prędkością wchodzenia odzwierciedla kontrolę nóg i równowagę — stałe tempo to dobry znak, a spadające warto obserwować.",
"breathingDisturbancesTitle": "Zaburzenia oddychania we śnie",
"breathingDisturbancesBody": "Twój Apple Watch potrafi oszacować, jak często Twój oddech był przerywany podczas snu. Niższy wskaźnik jest lepszy; poziom, który Apple oznacza jako podwyższony — zwłaszcza przez wiele nocy — warto omówić z lekarzem.",
+ "stepsTitle": "Kroki",
+ "stepsBody": "Liczba kroków to łączna liczba kroków wykonanych każdego dnia. To najprostsza miara codziennego ruchu — wyższa dzienna liczba zwykle odzwierciedla bardziej aktywny tryb życia, a stały nawyk liczy się bardziej niż pojedynczy intensywny dzień.",
"readinessBody": "Gotowość to pojedynczy dzienny odczyt tego, jak przygotowane jest Twoje ciało, by zmierzyć się z dniem — łączy tętno spoczynkowe, zmienność rytmu serca, sen i regularność ostatnich nocy. Wyższy wynik oznacza, że Twój organizm wygląda na wypoczęty i pełen zasobów; niższy to delikatna wskazówka, by zwolnić tempo.",
"sleepScoreBody": "Twój wynik snu streszcza całą noc w jednej liczbie — długość, efektywność, regularność pór oraz proporcje snu lekkiego, głębokiego i REM. Obserwuj wzorzec w ujęciu tygodni, zamiast gonić za idealnym wynikiem jednej nocy.",
"recoveryBody": "Regeneracja odzwierciedla, jak dobrze Twoje ciało się odbudowało — opiera się na nocnych sygnałach, takich jak zmienność rytmu serca i tętno spoczynkowe. Wysoki wynik to zielone światło, by przyspieszyć; niższy sugeruje, że organizm wciąż się regeneruje.",
@@ -2750,6 +2694,11 @@
"critical": "Krytyczny"
},
"assessmentTitle": "Ocena",
+ "restMode": {
+ "title": "Tryb odpoczynku",
+ "body": "Trwa epizod choroby lub dolegliwości, więc odzwierciedla to okres rekonwalescencji. Twój wynik nie jest za to obniżany.",
+ "bodySince": "Od {since} trwa epizod choroby lub dolegliwości, więc odzwierciedla to okres rekonwalescencji. Twój wynik nie jest za to obniżany."
+ },
"assessmentShowMore": "Pokaż więcej",
"assessmentShowLess": "Pokaż mniej",
"bloodPressureSectionTitle": "Ciśnienie krwi",
@@ -3461,7 +3410,8 @@
"watchVital": "Poza zakresem: {vital}. Często to jednorazowy odczyt — warto obserwować przez kilka dni.",
"firedHeadline": "Dziś {count} Twoich parametrów życiowych jest poza osobistym zakresem.",
"factors": "Możliwe czynniki — nigdy przyczyna: intensywny trening, słaby sen, alkohol, wysokość, stres lub choroba. To wskazówka z Twoich własnych baz, nie diagnoza.",
- "building": "Budujemy Twoje osobiste bazy — zarejestruj jeszcze kilka dni, a to się pojawi."
+ "building": "Budujemy Twoje osobiste bazy — zarejestruj jeszcze kilka dni, a to się pojawi.",
+ "illnessExplained": "Te odchylenia prawdopodobnie wynikają z zapisanego schorzenia."
},
"trajectory": {
"cardTitle": "Krótkoterminowy rzut",
@@ -3633,10 +3583,14 @@
"title": "Serce i energia w ciągu dnia",
"subtitle": "Całodobowe sumy sercowe i energetyczne"
},
- "scoreLink": {
- "title": "Wynik regeneracji",
- "subtitle": "Zobacz, jak rozkłada się nocny wynik",
- "cta": "Zobacz"
+ "block": {
+ "ansChargeExplainer": "Jak naładowany jest Twój autonomiczny układ nerwowy na dany dzień — odczyt regeneracji oparty na nocnych sygnałach serca i oddechu.",
+ "dayStrainExplainer": "Dzienne obciążenie sercowo-naczyniowe na skali wysiłku Twojego urządzenia. Odczytuj je w odniesieniu do regeneracji, nie jako cel sam w sobie.",
+ "workoutStrainExplainer": "Jak wymagający był pojedynczy trening na tej samej skali wysiłku — koszt sercowo-naczyniowy tej sesji.",
+ "cardioLoadExplainer": "Przybliżenie obciążenia treningowego, które śledzi nagromadzone obciążenie sercowo-naczyniowe. Porównuj je z własnymi ostatnimi dniami.",
+ "averageHeartRateExplainer": "Twoje średnie tętno w ciągu całego dnia lub cyklu, zgodnie z odczytem Twojego urządzenia.",
+ "maxHeartRateExplainer": "Twoje maksymalne tętno w ciągu całego dnia lub cyklu, zgodnie z odczytem Twojego urządzenia.",
+ "energyExpenditureKjExplainer": "Energia, którą Twój organizm wydatkował w ciągu dnia, w kilodżulach, zgodnie z natywnym odczytem urządzenia."
}
},
"bodyTempDeviation": {
@@ -3871,18 +3825,7 @@
},
"integrations": {
"title": "Integracje",
- "description": "Połączone usługi, kanały dostarczania i priorytet źródeł w jednym miejscu.",
- "tabs": {
- "connections": "Połączenia",
- "channels": "Kanały",
- "sources": "Źródła"
- },
- "connections": {
- "description": "Połącz Withings, WHOOP, Fitbit, Polar, Oura i Nightscout."
- },
- "channels": {
- "description": "Telegram, ntfy, Web Push, Webhook i e-mail — drogi dostarczania powiadomień."
- }
+ "description": "Połączone usługi, kanały dostarczania i priorytet źródeł w jednym miejscu."
},
"notifications": {
"title": "Powiadomienia",
@@ -3892,7 +3835,7 @@
"inboxLink": "Otwórz skrzynkę odbiorczą"
},
"layout": {
- "title": "Układ i personalizacja",
+ "title": "Układ",
"description": "Ustal, jak rozłożone są pulpit i analizy.",
"backToHub": "Powrót do Układ i personalizacja",
"dashboard": {
@@ -3902,14 +3845,6 @@
"insights": {
"title": "Analizy",
"description": "Kolejność przeglądu i pigułek nawigacyjnych."
- },
- "medications": {
- "title": "Leki",
- "description": "Widok listy i kolejność leków."
- },
- "mood": {
- "title": "Tagi nastroju",
- "description": "Grupy, tagi i kolejność w wyborze nastroju."
}
},
"dashboard": {
@@ -3996,12 +3931,6 @@
"description": "Pobierz swoje dane jako CSV lub pełną kopię zapasową JSON albo zaimportuj z Apple Health, JSON lub CSV. Raport dla lekarza znajdziesz teraz w Dokumentacji zdrowotnej.",
"otherOptionsHeading": "Inne opcje eksportu",
"downloadFailed": "Nie udało się pobrać eksportu. Spróbuj ponownie.",
- "hero": {
- "eyebrow": "Wizyta u lekarza",
- "valueStatement": "Raport PDF do druku na wizytę u lekarza — parametry życiowe, BMI, klasyfikacja ciśnienia, przestrzeganie leczenia oraz (opcjonalnie) zapis nastroju, na kilku stronach.",
- "cta": "Skonfiguruj i wygeneruj raport",
- "formatHint": "PDF · gotowe do druku"
- },
"actions": {
"download": "Pobierz"
},
@@ -4010,10 +3939,6 @@
"until": "Do"
},
"cards": {
- "doctorReport": {
- "title": "Raport dla lekarza",
- "description": "PDF do druku na wizytę lekarską — parametry życiowe, BMI, klasyfikacja ciśnienia, przestrzeganie leczenia, nastrój."
- },
"measurementsCsv": {
"title": "Pomiary",
"description": "Waga, ciśnienie, tętno, glukoza i wszystkie inne pomiary jako wartości rozdzielone przecinkami."
@@ -4146,6 +4071,10 @@
"coach": {
"title": "Coach",
"description": "Włącz Coacha, ton i zakres, zapisane wspomnienia."
+ },
+ "channels": {
+ "title": "Kanały",
+ "description": "Dostarczanie przez Telegram, ntfy, Web Push, Webhook i e-mail."
}
},
"profile": "Profil",
@@ -4387,14 +4316,6 @@
"clearedToast": "Zapomniano {count} zapisanych informacji.",
"clearError": "Nie udało się usunąć zapisanych informacji. Spróbuj ponownie."
},
- "disableCoach": {
- "title": "Ukryj Coacha",
- "description": "Ukrywa przycisk Coacha i panel na wszystkich stronach.",
- "toggleAria": "Ukryj przycisk Coacha i panel",
- "savedHidden": "Coach ukryty.",
- "savedShown": "Coach widoczny.",
- "saveError": "Nie udało się zapisać zmiany. Spróbuj ponownie."
- },
"aboutMe": {
"title": "O mnie",
"description": "Osobisty kontekst, który Coach i codzienny briefing czytają obok Twoich danych.",
@@ -4857,7 +4778,17 @@
"rate_limited": "Zbyt wiele prób połączenia. Poczekaj chwilę i spróbuj ponownie.",
"generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie."
},
- "integrationSetupGuide": "Przewodnik konfiguracji"
+ "integrationSetupGuide": "Przewodnik konfiguracji",
+ "coach": {
+ "activate": {
+ "title": "Włącz Coacha",
+ "description": "Pokazuje przycisk i panel Coacha. Domyślnie włączony — wyłącz, aby ukryć Coacha wszędzie.",
+ "toggleAria": "Włącz przycisk i panel Coacha",
+ "savedOn": "Coach włączony.",
+ "savedOff": "Coach ukryty.",
+ "saveError": "Nie udało się zapisać zmiany. Spróbuj ponownie."
+ }
+ }
},
"admin": {
"title": "Administracja",
@@ -5145,6 +5076,7 @@
"deleteAllConfirm": "Na pewno usunąć wszystkie dane?",
"deleteAllConfirmDescription": "Spowoduje to trwałe usunięcie wszystkich pomiarów, leków, zdarzeń przyjęcia, tokenów API, połączeń Withings, otwartych wyzwań uwierzytelniania, kanałów powiadomień (Telegram, ntfy, Web Push), subskrypcji push oraz zakolejkowanych usunięć wiadomości Telegram wszystkich użytkowników. Konta użytkowników, dziennik audytu i przesłane opinie zostają zachowane, aby samo usunięcie pozostało możliwe do prześledzenia, a zgłoszenia błędów — do obsłużenia.",
"deleteButton": "Usuń wszystkie dane",
+ "deleteAllPromptLabel": "Wpisz {token}, aby kontynuować",
"finalDelete": "Usuń trwale",
"deletedResult": "Deleted: {measurements} measurements, {medications} Leki,{intakeEvents} intakes, {apiTokens} API tokens, {withingsConnections} Withings connections, {authChallenges} auth challenges, {notificationChannels} notification channels, {pushSubscriptions} push subscriptions, {telegramScheduledDeletions} queued Telegram deletions. Feedback rows preserved.",
"authRegister": "Rejestracja",
@@ -5273,17 +5205,9 @@
"title": "Integracje",
"subtitle": "Skonfiguruj Umami, GlitchTip, Web Push i zgłoszenia błędów."
},
- "ai-quality": {
- "title": "Jakość analiz",
- "subtitle": "Oceny przydatności pogrupowane według ważności i dostawcy, z ostatnich 30 dni opinii użytkowników."
- },
- "assistant": {
- "title": "Powierzchnie asystenta",
- "subtitle": "Włącz lub wyłącz każdą powierzchnię opartą na modelu bez usuwania konfiguracji dostawcy."
- },
- "coach-feedback": {
- "title": "Opinie o Coachu",
- "subtitle": "Wskaźnik przydatności według wersji promptu, tonu i szczegółowości Coacha z ostatnich 30 dni."
+ "coach": {
+ "title": "Coach",
+ "subtitle": "Powierzchnie asystenta, klucz serwera oraz tabele jakości opinii Coacha i analiz w jednym miejscu."
},
"feedback": {
"title": "Opinie",
@@ -5875,11 +5799,6 @@
"sixDaysAgo": "6 dni temu"
}
},
- "relativeDay": {
- "today": "Dzisiaj",
- "yesterday": "Wczoraj",
- "daysAgo": "{count} dni temu"
- },
"edit": {
"openLabel": "Edytuj zakres docelowy dla {metric}",
"title": "Edytuj zakres docelowy — {metric}",
@@ -6344,7 +6263,9 @@
"delete": "Usuń",
"temperatureSection": "Temperatura i owulacja",
"intimacySection": "Intymność i antykoncepcja",
- "testsSection": "Testy"
+ "testsSection": "Testy",
+ "phaseDayHeader": "Dzień {day} · {phase}",
+ "bbtHint": "Zmierz ją rano zaraz po przebudzeniu, przed wstaniem."
},
"settings": {
"title": "Ustawienia cyklu",
@@ -6420,6 +6341,24 @@
"ovulationEstimate": "Szac. owulacja",
"tooltipTemp": "{temp} °C",
"ovulationConfirmed": "Potwierdzona owulacja"
+ },
+ "fieldInfo": {
+ "bbt": "Twoja temperatura spoczynkowa, mierzona rano zaraz po przebudzeniu, przed wstaniem. Po owulacji nieznacznie rośnie (o około 0,2–0,3 °C).",
+ "bbtLabel": "Czym jest temperatura podstawowa?",
+ "mucus": "Wydzielina z pochwy zmienia się w cyklu — od suchej lub lepkiej do kremowej, a następnie wodnistej i przypominającej białko jaja w najbardziej płodnych dniach.",
+ "mucusLabel": "Czym jest śluz szyjkowy?",
+ "ovulationTest": "Domowy test moczu wykrywający skok LH — wyrzut hormonu około 24–36 h przed owulacją. Wynik dodatni wskazuje najbardziej płodne okno.",
+ "ovulationTestLabel": "Czym jest test owulacyjny?",
+ "spotting": "Lekkie krwawienie poza okresem.",
+ "spottingLabel": "Czym jest plamienie między okresami?",
+ "pregnancyTest": "Wynik domowego testu ciążowego (hCG).",
+ "pregnancyTestLabel": "Czym jest test ciążowy?",
+ "progesteroneTest": "Test potwierdzający, że doszło do owulacji — progesteron rośnie po niej.",
+ "progesteroneTestLabel": "Czym jest test progesteronu?",
+ "intercourse": "Czy tego dnia doszło do stosunku.",
+ "intercourseLabel": "Co śledzi stosunek?",
+ "protected": "Czy podczas stosunku zastosowano antykoncepcję.",
+ "protectedLabel": "Co oznacza zabezpieczony?"
}
},
"cycleReminders": {
@@ -6478,7 +6417,19 @@
"BLOOD_GLUCOSE": "Glukoza we krwi",
"OXYGEN_SATURATION": "Saturacja tlenem",
"BODY_FAT": "Tkanka tłuszczowa",
- "BODY_TEMPERATURE": "Temperatura ciała"
+ "BODY_TEMPERATURE": "Temperatura ciała",
+ "FAT_MASS": "Masa tłuszczowa",
+ "FAT_FREE_MASS": "Masa beztłuszczowa",
+ "MUSCLE_MASS": "Masa mięśniowa",
+ "LEAN_BODY_MASS": "Masa szczupła",
+ "BONE_MASS": "Masa kostna",
+ "TOTAL_BODY_WATER": "Woda w organizmie",
+ "VISCERAL_FAT": "Tłuszcz trzewny",
+ "BODY_MASS_INDEX": "Wskaźnik masy ciała (BMI)"
+ },
+ "typeGroups": {
+ "vitals": "Parametry życiowe",
+ "bodyComposition": "Skład ciała"
},
"empty": {
"title": "Brak przypomnień",
@@ -6495,22 +6446,28 @@
"notifyHour": "Przypomnij o",
"location": "Gdzie (opcjonalnie)",
"locationPlaceholder": "np. Lekarz, Laboratorium",
- "save": "Zapisz"
+ "save": "Zapisz",
+ "createTitle": "Nowe przypomnienie",
+ "editTitle": "Edytuj przypomnienie"
},
"pushTitle": "Przypomnienie profilaktyczne",
"pushBody": "Czas na: {label}",
- "pushLocation": "(u {location})"
+ "pushLocation": "(u {location})",
+ "edit": "Edytuj",
+ "originCoach": "Coach",
+ "enabledToggleAria": "Włącz lub wyłącz to przypomnienie",
+ "until": "Do {date}"
},
"labs": {
"title": "Wyniki badań",
"subtitle": "Badania krwi i biomarkery",
- "addResult": "Dodaj",
+ "addResult": "Dodaj wynik",
"addDescription": "Zapisz biomarker z wyniku laboratoryjnego.",
"addFirst": "Dodaj pierwszy wynik",
"emptyTitle": "Brak wyników badań",
"emptyDescription": "Zapisuj badania takie jak HbA1c, LDL czy ferrytyna, aby śledzić je w czasie i udostępniać lekarzowi.",
"referenceLabel": "Norma",
- "readingsCount": "{count} wyników",
+ "readingsCount": "{count} pomiarów",
"deleteLatest": "Usuń ostatni wynik",
"deleteConfirmTitle": "Usunąć wynik laboratoryjny?",
"deleteConfirmDescription": "Ten wynik laboratoryjny zostanie trwale usunięty.",
@@ -6539,12 +6496,130 @@
"referenceHint": "Przepisz zakres referencyjny z wyniku laboratoryjnego. Każda z granic jest opcjonalna.",
"note": "Notatka",
"notePlaceholder": "Opcjonalny kontekst tego wyniku",
- "requiredError": "Biomarker, wartość i jednostka są wymagane.",
+ "requiredError": "Podaj wartość.",
"rangeOrderError": "Norma dolna nie może przekraczać górnej.",
"save": "Zapisz",
"savedToast": "Wynik zapisany",
- "saveError": "Nie udało się zapisać wyniku"
- }
+ "saveError": "Nie udało się zapisać wyniku",
+ "biomarker": "Biomarker",
+ "biomarkerPlaceholder": "Wybierz biomarker",
+ "defineNew": "+ Zdefiniuj nowy biomarker",
+ "noBiomarkersHint": "Najpierw zdefiniuj biomarker, aby zapisać wartość.",
+ "pickBiomarkerError": "Najpierw wybierz biomarker."
+ },
+ "backToLabs": "Wróć do wyników",
+ "hasNote": "Notatka",
+ "editTitle": "Edytuj pomiar",
+ "editDescription": "Popraw wartość, datę lub notatkę tego pomiaru.",
+ "editedToast": "Pomiar zaktualizowany",
+ "editError": "Nie udało się zaktualizować pomiaru",
+ "editTimestampError": "Podaj prawidłową datę.",
+ "editReading": "Edytuj pomiar",
+ "deleteReading": "Usuń pomiar",
+ "restoredToast": "Pomiar przywrócony",
+ "restoreError": "Nie udało się przywrócić pomiaru",
+ "detail": {
+ "history": "Historia",
+ "emptyTitle": "Brak pomiarów",
+ "emptyDescription": "Dodaj wartość, aby śledzić ten biomarker w czasie."
+ },
+ "chart": {
+ "empty": "Brak pomiarów w tym okresie.",
+ "valueLabel": "Wartość",
+ "range": {
+ "30": "30 d",
+ "90": "90 d",
+ "365": "1 r",
+ "all": "Wszystko"
+ }
+ },
+ "biomarker": {
+ "manage": "Biomarkery",
+ "manageTitle": "Biomarkery",
+ "manageSheetDescription": "Zdefiniuj śledzone markery. Każdy ustawiasz raz wraz z jednostką i zakresem referencyjnym.",
+ "managerDescription": "Zdefiniuj marker raz — jednostka i zakres są używane dla każdego pomiaru.",
+ "define": "Zdefiniuj",
+ "defineFirst": "Zdefiniuj pierwszy biomarker",
+ "defineTitle": "Zdefiniuj biomarker",
+ "defineDescription": "Ustaw nazwę, jednostkę i zakres raz. Marker wybierasz przy dodawaniu wartości.",
+ "editTitle": "Edytuj biomarker",
+ "edit": "Edytuj",
+ "delete": "Usuń",
+ "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.",
+ "emptyTitle": "Brak biomarkerów",
+ "emptyDescription": "Zdefiniuj markery, takie jak LDL lub HbA1c, aby zapisywać wartości.",
+ "loadError": "Nie udało się wczytać biomarkerów",
+ "noRange": "Brak zakresu referencyjnego",
+ "form": {
+ "seedLabel": "Zacznij od popularnego markera",
+ "seedPlaceholder": "Wybierz marker (opcjonalnie)",
+ "seedHint": "Wybór wypełnia pola poniżej — dopasuj zakres do swojego laboratorium.",
+ "name": "Nazwa",
+ "namePlaceholder": "np. Cholesterol LDL",
+ "unit": "Jednostka",
+ "unitPlaceholder": "np. mg/dl",
+ "panel": "Panel",
+ "panelPlaceholder": "np. Lipidy",
+ "lowerBound": "Dolna granica",
+ "upperBound": "Górna granica",
+ "lowerPlaceholder": "Dolna granica",
+ "upperPlaceholder": "Górna granica",
+ "context": "Kontekst",
+ "contextPlaceholder": "Opcjonalna notatka o tym markerze",
+ "rangeHint": "Obie granice są opcjonalne — zostaw jedną pustą dla zakresu otwartego (np. LDL < 116).",
+ "requiredError": "Nazwa i jednostka są wymagane.",
+ "rangeOrderError": "Dolna granica nie może przekraczać górnej.",
+ "save": "Zapisz biomarker",
+ "savedToast": "Biomarker zapisany",
+ "updatedToast": "Biomarker zaktualizowany",
+ "saveError": "Nie udało się zapisać biomarkera"
+ }
+ },
+ "catalog": {
+ "total-cholesterol": "Cholesterol całkowity",
+ "ldl": "Cholesterol LDL",
+ "hdl": "Cholesterol HDL",
+ "triglycerides": "Trójglicerydy",
+ "fasting-glucose": "Glukoza na czczo",
+ "hba1c": "HbA1c",
+ "tsh": "TSH",
+ "ft3": "Wolne T3",
+ "ft4": "Wolne T4",
+ "ferritin": "Ferrytyna",
+ "transferrin-saturation": "Wysycenie transferyny",
+ "vitamin-d": "Witamina D (25-OH)",
+ "vitamin-b12": "Witamina B12",
+ "folate": "Kwas foliowy",
+ "hs-crp": "hs-CRP",
+ "creatinine": "Kreatynina",
+ "egfr": "eGFR",
+ "alt": "ALT",
+ "ast": "AST",
+ "ggt": "GGTP",
+ "sodium": "Sód",
+ "potassium": "Potas",
+ "hemoglobin": "Hemoglobina",
+ "hematocrit": "Hematokryt",
+ "wbc": "Leukocyty",
+ "platelets": "Płytki krwi",
+ "panel": {
+ "lipids": "Lipidy",
+ "glucose": "Metabolizm glukozy",
+ "thyroid": "Tarczyca",
+ "iron": "Żelazo",
+ "vitamins": "Witaminy",
+ "inflammation": "Stan zapalny",
+ "renal": "Nerki",
+ "liver": "Wątroba",
+ "electrolytes": "Elektrolity",
+ "bloodCount": "Morfologia krwi"
+ }
+ },
+ "noteLoadFailed": "Nie udało się wczytać notatki; pozostanie niezmieniona po zapisaniu.",
+ "showingLatestOf": "Wyświetlanie najnowszych {shown} z {total} wyników"
},
"modules": {
"cycle": {
@@ -6584,7 +6659,7 @@
"description": "Czat AI oparty na Twoich danych."
},
"insights": {
- "label": "Analizy",
+ "label": "Insights",
"description": "Analizy AI, briefing i korelacje."
},
"doctorReport": {
@@ -6606,6 +6681,165 @@
"medications": {
"label": "Leki",
"description": "Schematy, przyjęcia i zgodność."
+ },
+ "illness": {
+ "label": "Dziennik chorób",
+ "description": "Zapisuj epizody chorób i dolegliwości z dziennym przebiegiem objawów."
+ }
+ },
+ "illness": {
+ "title": "Dziennik chorób",
+ "subtitle": "Retrospektywny zapis Twoich epizodów chorób i dolegliwości.",
+ "disclaimer": "Osobisty dziennik — nie wyrób medyczny. Nie diagnozuje ani nie przewiduje.",
+ "newEpisode": "Nowy epizod",
+ "logDay": "Zapisz dzień",
+ "markRecovered": "Oznacz jako wyleczone",
+ "onsetOn": "Początek {date}",
+ "recoveredOn": "wyleczono {date}",
+ "status": {
+ "active": "Aktywny",
+ "ongoing": "Trwający",
+ "recovered": "Wyleczony"
+ },
+ "empty": {
+ "title": "Brak epizodów",
+ "body": "Rozpocznij epizod, aby prowadzić dzienny przebieg objawów i prześledzić, jak się rozwijał."
+ },
+ "type": {
+ "INFECTION": "Infekcja",
+ "ALLERGY": "Alergia",
+ "INJURY": "Uraz",
+ "MENTAL_HEALTH": "Zdrowie psychiczne",
+ "AUTOIMMUNE": "Autoimmunologiczna",
+ "CHRONIC": "Przewlekła",
+ "OTHER": "Inne"
+ },
+ "lifecycle": {
+ "ACUTE": "Ostra",
+ "CHRONIC_ONGOING": "Przewlekła / trwająca",
+ "RECURRING": "Nawracająca",
+ "FLARE": "Zaostrzenie"
+ },
+ "new": {
+ "title": "Nowy epizod",
+ "description": "Zapisz chorobę lub dolegliwość. Codzienne objawy możesz notować później.",
+ "label": "Nazwa",
+ "labelPlaceholder": "np. Przeziębienie",
+ "type": "Typ",
+ "lifecycle": "Przebieg",
+ "onset": "Początek",
+ "note": "Notatka",
+ "notePlaceholder": "Cokolwiek wartego zapamiętania (zaszyfrowane).",
+ "saveError": "Nie udało się zapisać epizodu. Spróbuj ponownie.",
+ "parent": "Part of",
+ "parentNone": "Not linked"
+ },
+ "sheet": {
+ "title": "Zapisz {date}",
+ "description": "Zanotuj dzisiejsze objawy, swoją sprawność i ewentualną gorączkę.",
+ "symptoms": "Objawy",
+ "functionalImpact": "Sprawność",
+ "fever": "Temperatura",
+ "note": "Notatka",
+ "noteSet": "Ustawiona",
+ "notePlaceholder": "Jak minął dzień (zaszyfrowane).",
+ "severityFor": "Nasilenie: {symptom}",
+ "saveError": "Nie udało się zapisać dnia. Spróbuj ponownie.",
+ "day": "Day"
+ },
+ "impact": {
+ "0": "Pełna sprawność",
+ "1": "Lekko ograniczona",
+ "2": "Głównie odpoczynek",
+ "3": "W łóżku"
+ },
+ "symptom": {
+ "runnyNose": "Katar",
+ "stuffyNose": "Zatkany nos",
+ "sneezing": "Kichanie",
+ "soreThroat": "Ból gardła",
+ "cough": "Kaszel",
+ "headache": "Ból głowy",
+ "bodyAches": "Bóle ciała",
+ "fatigue": "Zmęczenie"
+ },
+ "correlation": {
+ "title": "Jak przebiegało",
+ "learning": "Wciąż się uczę — zapisz jeszcze kilka dni, aby zobaczyć tu wzorce.",
+ "preOnsetTitle": "Jak się zapowiadało",
+ "preOnsetBody": "Sygnały, które odbiegły od Twojej bazy przed oznaczonym początkiem.",
+ "nadirTitle": "Co się zmieniło",
+ "nadirBody": "Jak daleko każdy śledzony sygnał odbiegł od Twojego typowego zakresu.",
+ "recoveryGapTitle": "Luka rekonwalescencji",
+ "recoveryGapLag": "Twoje wartości ustabilizowały się {days} dni po tym, jak poczułeś się lepiej.",
+ "recoveryGapLead": "Twoje wartości ustabilizowały się {days} dni przed oznaczeniem wyzdrowienia.",
+ "recoveryGapSame": "Twoje wartości ustabilizowały się tego samego dnia, gdy poczułeś się lepiej.",
+ "redFlagTitle": "Warto przyjrzeć się bliżej",
+ "redFlagSpo2": "Twoje wysycenie tlenem pozostawało niskie przez kilka dni. Jeśli się powtórzy, zasięgnij pomocy medycznej.",
+ "redFlagFever": "Miałeś utrzymującą się gorączkę. Jeśli się powtórzy, zasięgnij pomocy medycznej.",
+ "direction": {
+ "above": "powyżej Twojej normy",
+ "below": "poniżej Twojej normy"
+ }
+ },
+ "insights": {
+ "title": "Twoja historia chorób",
+ "countOne": "W tym okresie zapisałeś 1 epizod.",
+ "countOther": "W tym okresie zapisałeś {count} epizodów.",
+ "typicalGap": "Typowa luka rekonwalescencji: {days} dni między poprawą a ustabilizowaniem wartości.",
+ "typicalGapLearning": "Jeszcze kilka zakończonych epizodów i pojawi się tu typowa luka rekonwalescencji.",
+ "byMonth": "Kiedy zwykle zapisujesz epizody",
+ "none": "Brak epizodów zapisanych w tym okresie."
+ },
+ "loadError": "Could not load this episode.",
+ "backToList": "Back to journal",
+ "detail": {
+ "note": "Note"
+ },
+ "edit": {
+ "title": "Edit episode",
+ "description": "Update this episode's details."
+ },
+ "menu": {
+ "label": "Episode actions"
+ },
+ "deleteConfirm": {
+ "title": "Delete this episode?",
+ "body": "“{label}” and all its day-logs will be removed. This cannot be undone."
+ },
+ "vital": {
+ "RESTING_HEART_RATE": "Resting heart rate",
+ "HEART_RATE_VARIABILITY": "Heart-rate variability",
+ "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",
+ "PULSE": "Pulse",
+ "WEIGHT": "Weight"
+ }
+ },
+ "coach": {
+ "reminderSuggestion": {
+ "title": "Ustawić przypomnienie?",
+ "accept": "Ustaw przypomnienie",
+ "dismiss": "Nie teraz",
+ "stop": "Mierzę wystarczająco",
+ "accepted": "Przypomnienie ustawione",
+ "dismissed": "Dobrze, nie zaproponuję tego ponownie",
+ "stopped": "Rozumiem, przestanę proponować przypomnienia",
+ "failed": "Nie udało się ustawić, spróbuj ponownie",
+ "cadence": {
+ "weightDaily": "Waż się codziennie, mniej więcej o tej samej porze",
+ "bp722": "Mierz ciśnienie dwa razy dziennie przez tydzień",
+ "glucoseStructured": "Mierz glukozę według ustrukturyzowanego planu dziennego"
+ }
+ },
+ "settings": {
+ "reminderSuggestionsLabel": "Sugestie częstotliwości",
+ "reminderSuggestionsDescription": "Pozwól Coachowi od czasu do czasu zaproponować oparte na dowodach przypomnienie, aby mierzyć daną wartość bardziej regularnie."
}
}
}
diff --git a/next.config.ts b/next.config.ts
index b4f889925..7413f86b2 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -121,15 +121,9 @@ const nextConfig: NextConfig = {
destination: "/coach",
permanent: true,
},
- // v1.18.0 (S3) — Sources folded into Settings → Integrations as the
- // "Sources" sub-tab; the standalone `/settings/sources` page is gone.
- // 301-redirect so bookmarks, the PWA's cached navigation, and the old
- // cross-links keep resolving.
- {
- source: "/settings/sources",
- destination: "/settings/integrations",
- permanent: true,
- },
+ // v1.18.1 (D4) — Sources is a standalone `/settings/sources` route again
+ // (split back out of the Integrations sub-tabs), so the v1.18.0 redirect
+ // to `/settings/integrations` was dropped.
// v1.18.0 (S4) — the standalone "Erinnerungen" hub at
// `/settings/reminders` was a link-only page that duplicated the
// canonical editors. Reminder TYPES now live in Notifications, each
diff --git a/package.json b/package.json
index 2058f15ce..7482279c1 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "healthlog",
- "version": "1.18.0",
+ "version": "1.18.1",
"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",
@@ -128,6 +128,10 @@
"onlyBuiltDependencies": [
"prisma",
"@prisma/engines"
- ]
+ ],
+ "overrides": {
+ "hono@<4.12.25": "^4.12.25",
+ "picomatch@<4.0.4": "^4.0.4"
+ }
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 36bc054e3..15afc4d70 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -4,6 +4,10 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
+overrides:
+ hono@<4.12.25: ^4.12.25
+ picomatch@<4.0.4: ^4.0.4
+
importers:
.:
@@ -866,13 +870,13 @@ packages:
resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==}
engines: {node: '>=18.14.1'}
peerDependencies:
- hono: ^4
+ hono: ^4.12.25
'@hono/node-server@1.19.14':
resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==}
engines: {node: '>=18.14.1'}
peerDependencies:
- hono: ^4
+ hono: ^4.12.25
'@hookform/resolvers@5.4.0':
resolution: {integrity: sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==}
@@ -4171,7 +4175,7 @@ packages:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
peerDependencies:
- picomatch: ^3 || ^4
+ picomatch: ^4.0.4
peerDependenciesMeta:
picomatch:
optional: true
@@ -4412,12 +4416,8 @@ packages:
hermes-parser@0.25.1:
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
- hono@4.12.15:
- resolution: {integrity: sha512-qM0jDhFEaCBb4TxoW7f53Qrpv9RBiayUHo0S52JudprkhvpjIrGoU1mnnr29Fvd1U335ZFPZQY1wlkqgfGXyLg==}
- engines: {node: '>=16.9.0'}
-
- hono@4.12.23:
- resolution: {integrity: sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==}
+ hono@4.12.25:
+ resolution: {integrity: sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==}
engines: {node: '>=16.9.0'}
html-escaper@2.0.2:
@@ -5444,10 +5444,6 @@ packages:
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
- picomatch@2.3.2:
- resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
- engines: {node: '>=8.6'}
-
picomatch@4.0.4:
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
engines: {node: '>=12'}
@@ -7516,13 +7512,13 @@ snapshots:
'@hexagon/base64@1.1.28': {}
- '@hono/node-server@1.19.11(hono@4.12.15)':
+ '@hono/node-server@1.19.11(hono@4.12.25)':
dependencies:
- hono: 4.12.15
+ hono: 4.12.25
- '@hono/node-server@1.19.14(hono@4.12.23)':
+ '@hono/node-server@1.19.14(hono@4.12.25)':
dependencies:
- hono: 4.12.23
+ hono: 4.12.25
'@hookform/resolvers@5.4.0(react-hook-form@7.78.0(react@19.2.7))':
dependencies:
@@ -7706,7 +7702,7 @@ snapshots:
'@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)':
dependencies:
- '@hono/node-server': 1.19.14(hono@4.12.23)
+ '@hono/node-server': 1.19.14(hono@4.12.25)
ajv: 8.20.0
ajv-formats: 3.0.1(ajv@8.20.0)
content-type: 1.0.5
@@ -7716,7 +7712,7 @@ snapshots:
eventsource-parser: 3.1.0
express: 5.2.1
express-rate-limit: 8.5.2(express@5.2.1)
- hono: 4.12.23
+ hono: 4.12.25
jose: 6.2.3
json-schema-typed: 8.0.2
pkce-challenge: 5.0.1
@@ -8202,13 +8198,13 @@ snapshots:
'@electric-sql/pglite': 0.4.1
'@electric-sql/pglite-socket': 0.1.1(@electric-sql/pglite@0.4.1)
'@electric-sql/pglite-tools': 0.3.1(@electric-sql/pglite@0.4.1)
- '@hono/node-server': 1.19.11(hono@4.12.15)
+ '@hono/node-server': 1.19.11(hono@4.12.25)
'@prisma/get-platform': 7.2.0
'@prisma/query-plan-executor': 7.2.0
'@prisma/streams-local': 0.1.2
foreground-child: 3.3.1
get-port-please: 3.2.0
- hono: 4.12.15
+ hono: 4.12.25
http-status-codes: 2.3.0
pathe: 2.0.3
proper-lockfile: 4.1.2
@@ -11132,9 +11128,7 @@ snapshots:
dependencies:
hermes-estree: 0.25.1
- hono@4.12.15: {}
-
- hono@4.12.23: {}
+ hono@4.12.25: {}
html-escaper@2.0.2: {}
@@ -11689,7 +11683,7 @@ snapshots:
micromatch@4.0.8:
dependencies:
braces: 3.0.3
- picomatch: 2.3.2
+ picomatch: 4.0.4
mime-db@1.54.0: {}
@@ -12131,8 +12125,6 @@ snapshots:
picocolors@1.1.1: {}
- picomatch@2.3.2: {}
-
picomatch@4.0.4: {}
pkce-challenge@5.0.1: {}
diff --git a/prisma/migrations/0169_v1181_reminder_engine_fields/migration.sql b/prisma/migrations/0169_v1181_reminder_engine_fields/migration.sql
new file mode 100644
index 000000000..d33de4079
--- /dev/null
+++ b/prisma/migrations/0169_v1181_reminder_engine_fields/migration.sql
@@ -0,0 +1,47 @@
+-- v1.19.0 — reminder-engine fields for the "one engine" unification.
+--
+-- Two additive columns on `measurement_reminders` so the AI coach can
+-- produce cadence reminders into the SAME table the Vorsorge surface uses
+-- (one model, one recurrence helper, one cron, one create endpoint — not a
+-- second "coach reminder" table):
+--
+-- * `ends_on` (nullable timestamp) — an optional course-window end. NULL =
+-- open-ended cadence (the existing behaviour, every current row). Non-NULL
+-- bounds a finite cadence so a Coach "measure BP morning+evening for a
+-- week" self-expires: the recurrence engine stops producing occurrences
+-- past this instant (the `medication.ends_on` precedent the engine already
+-- supports).
+--
+-- * `origin` (enum `reminder_origin`, default `VORSORGE`) — provenance.
+-- `VORSORGE` = the user created it through the Vorsorge surface (the
+-- existing behaviour; backfilled onto every current row by the column
+-- default). `COACH` = the coach minted it from an evidence-based cadence
+-- suggestion. Lets the UI label provenance and lets Coach dedupe its own
+-- suggestions.
+--
+-- No backfill needed: `ends_on` defaults NULL (open-ended) and `origin`
+-- defaults `VORSORGE`, so every existing reminder keeps its current
+-- behaviour. No new index — the cron already rides `(user_id, next_due_at)`
+-- and these two columns are read alongside the row, never scanned on.
+--
+-- Idempotent guards (`IF NOT EXISTS` / `DO $$`) so reruns are safe on prod.
+--
+-- Reversibility (down):
+-- ALTER TABLE "measurement_reminders" DROP COLUMN IF EXISTS "origin";
+-- ALTER TABLE "measurement_reminders" DROP COLUMN IF EXISTS "ends_on";
+-- DROP TYPE IF EXISTS "reminder_origin";
+-- A roll-back loses the coach/vorsorge distinction + any course-window end
+-- (every reminder falls back to open-ended user-created), which is the safe
+-- default — no data is destroyed.
+
+-- CreateEnum
+DO $$ BEGIN
+ CREATE TYPE "reminder_origin" AS ENUM ('VORSORGE', 'COACH');
+EXCEPTION
+ WHEN duplicate_object THEN null;
+END $$;
+
+-- AlterTable
+ALTER TABLE "measurement_reminders"
+ ADD COLUMN IF NOT EXISTS "ends_on" TIMESTAMP(3),
+ ADD COLUMN IF NOT EXISTS "origin" "reminder_origin" NOT NULL DEFAULT 'VORSORGE';
diff --git a/prisma/migrations/0170_v1181_biomarker_catalog/migration.sql b/prisma/migrations/0170_v1181_biomarker_catalog/migration.sql
new file mode 100644
index 000000000..cafe77df5
--- /dev/null
+++ b/prisma/migrations/0170_v1181_biomarker_catalog/migration.sql
@@ -0,0 +1,80 @@
+-- v1.18.1 — user-scoped biomarker catalog.
+--
+-- The lean v1.17.1 `lab_results` row stores analyte / unit / reference_low /
+-- reference_high on EVERY row, so recording the same marker three times means
+-- re-typing the name + unit + both bounds three times — each an opportunity
+-- to fork the "same" biomarker into "LDL" / "ldl" / "LDL-C" or mistype
+-- `mg/dL` as `mg/dl`. This adds a per-user catalog that defines a marker ONCE
+-- (canonical name, unit, reference bounds, optional context note); readings
+-- link to it and resolve their range/unit from the catalog row.
+--
+-- * `biomarkers` table — user-scoped, unique per (user_id, name) so there
+-- is never two "LDL" definitions. `context_encrypted` is a Bytes column
+-- (the AES-256-GCM `*Encrypted` convention). `lower_bound` / `upper_bound`
+-- are the optional reference window; `panel` is the optional grouping
+-- moved off the per-entry row.
+--
+-- * `lab_results.biomarker_id` (nullable FK) — every existing row stays
+-- NULL. A pg-boss backfill job (not a tsx CLI per the standalone-image
+-- rule) will later GROUP BY lower(analyte) per user, mint one Biomarker
+-- per group, and link the rows. This migration only adds the column +
+-- relation + supporting index; no row is touched, no data destroyed.
+-- ON DELETE SET NULL: deleting a catalog marker orphans its readings
+-- (they keep their legacy analyte/unit/reference columns) rather than
+-- cascading the historical values away.
+--
+-- Additive throughout; no backfill in the migration itself.
+--
+-- Idempotent guards (`IF NOT EXISTS`) so reruns are safe on prod. The FK is
+-- added under a DO-block existence guard for the same reason.
+--
+-- Reversibility (down):
+-- ALTER TABLE "lab_results" DROP CONSTRAINT IF EXISTS "lab_results_biomarker_id_fkey";
+-- DROP INDEX IF EXISTS "lab_results_biomarker_id_idx";
+-- ALTER TABLE "lab_results" DROP COLUMN IF EXISTS "biomarker_id";
+-- DROP TABLE IF EXISTS "biomarkers";
+-- A roll-back drops the catalog + un-links readings (which keep their legacy
+-- per-row analyte/unit/reference values), the safe default — no reading lost.
+
+-- CreateTable
+CREATE TABLE IF NOT EXISTS "biomarkers" (
+ "id" TEXT NOT NULL,
+ "user_id" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "unit" TEXT NOT NULL,
+ "lower_bound" DOUBLE PRECISION,
+ "upper_bound" DOUBLE PRECISION,
+ "context_encrypted" BYTEA,
+ "panel" TEXT,
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "biomarkers_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX IF NOT EXISTS "biomarkers_user_id_name_key" ON "biomarkers"("user_id", "name");
+
+-- CreateIndex
+CREATE INDEX IF NOT EXISTS "biomarkers_user_id_idx" ON "biomarkers"("user_id");
+
+-- AlterTable
+ALTER TABLE "lab_results"
+ ADD COLUMN IF NOT EXISTS "biomarker_id" TEXT;
+
+-- CreateIndex
+CREATE INDEX IF NOT EXISTS "lab_results_biomarker_id_idx" ON "lab_results"("biomarker_id");
+
+-- AddForeignKey
+DO $$ BEGIN
+ ALTER TABLE "biomarkers" ADD CONSTRAINT "biomarkers_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 "lab_results" ADD CONSTRAINT "lab_results_biomarker_id_fkey" FOREIGN KEY ("biomarker_id") REFERENCES "biomarkers"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+EXCEPTION
+ WHEN duplicate_object THEN null;
+END $$;
diff --git a/prisma/migrations/0171_v1181_illness_episode/migration.sql b/prisma/migrations/0171_v1181_illness_episode/migration.sql
new file mode 100644
index 000000000..b5aa4577f
--- /dev/null
+++ b/prisma/migrations/0171_v1181_illness_episode/migration.sql
@@ -0,0 +1,203 @@
+-- v1.18.1 — illness / condition journal.
+--
+-- A CONDITION journal (not a flu log): any illness/condition kept for
+-- oneself, with a retrospective lens. Retrospective + correlated, NEVER a
+-- predictor/diagnoser. Born-gated by the module system — the gate is
+-- app-layer, so there is no enable flag in the schema.
+--
+-- Tables (the daily timeline mirrors the Cycle three-table shape):
+--
+-- * `illness_episodes` — one episode. `illness_type` widens beyond an
+-- infection log (infection / allergy / injury / mental-health /
+-- autoimmune / chronic / other). `illness_lifecycle`
+-- (ACUTE / CHRONIC_ONGOING / RECURRING / FLARE) distinguishes a bout from
+-- an ongoing chronic condition; CHRONIC_ONGOING carries no resolved_at
+-- and is excluded from the recovery-gap math. `parent_condition_id` is a
+-- self-FK (ON DELETE SET NULL) so a flare/recurrence threads under one
+-- parent condition. `note_encrypted` is a Bytes column (the AES-256-GCM
+-- `*Encrypted` convention). Soft-deleted.
+--
+-- * `illness_day_logs` — one row per day of an episode (mirrors
+-- cycle_day_logs): a coarse 0–3 `functional_impact` slider, an optional
+-- `fever_c` reading (queryable plaintext, the basal_body_temp_c
+-- precedent), an encrypted day note, the day's symptom links. Unique per
+-- (episode_id, date). Soft-deleted.
+--
+-- * `illness_symptoms` — a seeded, icon-bearing, labelKey-i18n catalog
+-- (Jackson / WURSS-derived; the cycle_symptoms precedent). Eight
+-- high-signal cold/illness symptoms, scored 0–3 on the link.
+--
+-- * `illness_symptom_links` — the per-day-log join, composite PK
+-- (day_log_id, symptom_id), 0–3 severity, cascade on both sides.
+--
+-- Additive; no existing row touched. The catalog seed uses deterministic
+-- ids + ON CONFLICT ("key") DO NOTHING so re-running is a no-op (the
+-- cycle/mood-tag seed precedent). Keep in sync with the app-side taxonomy
+-- module if either changes.
+--
+-- Idempotent guards (`IF NOT EXISTS` / `DO $$`) so reruns are safe on prod.
+--
+-- Reversibility (down):
+-- DROP TABLE IF EXISTS "illness_symptom_links";
+-- DROP TABLE IF EXISTS "illness_symptoms";
+-- DROP TABLE IF EXISTS "illness_day_logs";
+-- DROP TABLE IF EXISTS "illness_episodes";
+-- DROP TYPE IF EXISTS "illness_lifecycle";
+-- DROP TYPE IF EXISTS "illness_type";
+-- A roll-back drops the journal wholesale — no other domain depends on it.
+
+-- CreateEnum
+DO $$ BEGIN
+ CREATE TYPE "illness_type" AS ENUM ('INFECTION', 'ALLERGY', 'INJURY', 'MENTAL_HEALTH', 'AUTOIMMUNE', 'CHRONIC', 'OTHER');
+EXCEPTION
+ WHEN duplicate_object THEN null;
+END $$;
+
+-- CreateEnum
+DO $$ BEGIN
+ CREATE TYPE "illness_lifecycle" AS ENUM ('ACUTE', 'CHRONIC_ONGOING', 'RECURRING', 'FLARE');
+EXCEPTION
+ WHEN duplicate_object THEN null;
+END $$;
+
+-- CreateTable
+CREATE TABLE IF NOT EXISTS "illness_episodes" (
+ "id" TEXT NOT NULL,
+ "user_id" TEXT NOT NULL,
+ "label" TEXT NOT NULL,
+ "type" "illness_type" NOT NULL,
+ "lifecycle" "illness_lifecycle" NOT NULL DEFAULT 'ACUTE',
+ "onset_at" TIMESTAMP(3) NOT NULL,
+ "resolved_at" TIMESTAMP(3),
+ "parent_condition_id" TEXT,
+ "note_encrypted" BYTEA,
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMP(3) NOT NULL,
+ "deleted_at" TIMESTAMP(3),
+
+ CONSTRAINT "illness_episodes_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE IF NOT EXISTS "illness_day_logs" (
+ "id" TEXT NOT NULL,
+ "user_id" TEXT NOT NULL,
+ "episode_id" TEXT NOT NULL,
+ "date" TEXT NOT NULL,
+ "functional_impact" INTEGER,
+ "fever_c" DOUBLE PRECISION,
+ "note_encrypted" BYTEA,
+ "tz" TEXT,
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMP(3) NOT NULL,
+ "deleted_at" TIMESTAMP(3),
+
+ CONSTRAINT "illness_day_logs_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE IF NOT EXISTS "illness_symptoms" (
+ "id" TEXT NOT NULL,
+ "key" TEXT NOT NULL,
+ "label_key" TEXT NOT NULL,
+ "icon" TEXT,
+ "sort_order" INTEGER NOT NULL DEFAULT 0,
+ "is_active" BOOLEAN NOT NULL DEFAULT true,
+
+ CONSTRAINT "illness_symptoms_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE IF NOT EXISTS "illness_symptom_links" (
+ "day_log_id" TEXT NOT NULL,
+ "symptom_id" TEXT NOT NULL,
+ "severity" INTEGER,
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "illness_symptom_links_pkey" PRIMARY KEY ("day_log_id","symptom_id")
+);
+
+-- CreateIndex
+CREATE INDEX IF NOT EXISTS "illness_episodes_user_id_deleted_at_idx" ON "illness_episodes"("user_id", "deleted_at");
+
+-- CreateIndex
+CREATE INDEX IF NOT EXISTS "illness_episodes_user_id_onset_at_idx" ON "illness_episodes"("user_id", "onset_at");
+
+-- CreateIndex
+CREATE INDEX IF NOT EXISTS "illness_episodes_parent_condition_id_idx" ON "illness_episodes"("parent_condition_id");
+
+-- CreateIndex
+CREATE UNIQUE INDEX IF NOT EXISTS "illness_day_logs_episode_id_date_key" ON "illness_day_logs"("episode_id", "date");
+
+-- CreateIndex
+CREATE INDEX IF NOT EXISTS "illness_day_logs_user_id_date_idx" ON "illness_day_logs"("user_id", "date");
+
+-- CreateIndex
+CREATE INDEX IF NOT EXISTS "illness_day_logs_episode_id_idx" ON "illness_day_logs"("episode_id");
+
+-- CreateIndex
+CREATE UNIQUE INDEX IF NOT EXISTS "illness_symptoms_key_key" ON "illness_symptoms"("key");
+
+-- CreateIndex
+CREATE INDEX IF NOT EXISTS "illness_symptoms_sort_order_idx" ON "illness_symptoms"("sort_order");
+
+-- CreateIndex
+CREATE INDEX IF NOT EXISTS "illness_symptom_links_symptom_id_idx" ON "illness_symptom_links"("symptom_id");
+
+-- AddForeignKey
+DO $$ BEGIN
+ ALTER TABLE "illness_episodes" ADD CONSTRAINT "illness_episodes_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 "illness_episodes" ADD CONSTRAINT "illness_episodes_parent_condition_id_fkey" FOREIGN KEY ("parent_condition_id") REFERENCES "illness_episodes"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+EXCEPTION
+ WHEN duplicate_object THEN null;
+END $$;
+
+-- AddForeignKey
+DO $$ BEGIN
+ ALTER TABLE "illness_day_logs" ADD CONSTRAINT "illness_day_logs_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 "illness_day_logs" ADD CONSTRAINT "illness_day_logs_episode_id_fkey" FOREIGN KEY ("episode_id") REFERENCES "illness_episodes"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+EXCEPTION
+ WHEN duplicate_object THEN null;
+END $$;
+
+-- AddForeignKey
+DO $$ BEGIN
+ ALTER TABLE "illness_symptom_links" ADD CONSTRAINT "illness_symptom_links_day_log_id_fkey" FOREIGN KEY ("day_log_id") REFERENCES "illness_day_logs"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+EXCEPTION
+ WHEN duplicate_object THEN null;
+END $$;
+
+-- AddForeignKey
+DO $$ BEGIN
+ ALTER TABLE "illness_symptom_links" ADD CONSTRAINT "illness_symptom_links_symptom_id_fkey" FOREIGN KEY ("symptom_id") REFERENCES "illness_symptoms"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+EXCEPTION
+ WHEN duplicate_object THEN null;
+END $$;
+
+-- ─── Seed the default illness-symptom catalog ────────────────────────
+-- Jackson/WURSS-derived high-signal cold/illness symptoms, scored 0–3 on
+-- the link. Idempotent: deterministic ids + ON CONFLICT ("key") DO NOTHING
+-- (the cycle_symptoms seed precedent 0129). Keep in sync with the app-side
+-- illness symptom taxonomy if either changes.
+INSERT INTO "illness_symptoms" ("id", "key", "label_key", "icon", "sort_order", "is_active") VALUES
+ ('is_runny_nose', 'runny_nose', 'illness.symptom.runnyNose', 'Droplets', 0, true),
+ ('is_stuffy_nose', 'stuffy_nose', 'illness.symptom.stuffyNose', 'Wind', 1, true),
+ ('is_sneezing', 'sneezing', 'illness.symptom.sneezing', 'Waves', 2, true),
+ ('is_sore_throat', 'sore_throat', 'illness.symptom.soreThroat', 'Flame', 3, true),
+ ('is_cough', 'cough', 'illness.symptom.cough', 'Megaphone', 4, true),
+ ('is_headache', 'headache', 'illness.symptom.headache', 'Brain', 5, true),
+ ('is_body_aches', 'body_aches', 'illness.symptom.bodyAches', 'PersonStanding', 6, true),
+ ('is_fatigue', 'fatigue', 'illness.symptom.fatigue', 'BatteryLow', 7, true)
+ON CONFLICT ("key") DO NOTHING;
diff --git a/prisma/migrations/0172_v1181_coach_reminder_unique/migration.sql b/prisma/migrations/0172_v1181_coach_reminder_unique/migration.sql
new file mode 100644
index 000000000..e978b7cf4
--- /dev/null
+++ b/prisma/migrations/0172_v1181_coach_reminder_unique/migration.sql
@@ -0,0 +1,32 @@
+-- v1.18.1 — structural accept-dedup for COACH-minted measurement reminders.
+--
+-- The Coach can mint a `MeasurementReminder` (origin: COACH) from an
+-- evidence-based cadence suggestion. The accept endpoint already does a
+-- read-then-create dedup, but a read-then-create is racy: two near-simultaneous
+-- accepts (a re-tap, or a stale card replayed) can both pass the existence
+-- check and create twins. Close the race at the database with a PARTIAL unique
+-- index: at most one LIVE COACH reminder per (user, metric). VORSORGE rows are
+-- unaffected, and a soft-deleted (deleted_at IS NOT NULL) COACH row never
+-- blocks a fresh accept of the same cadence.
+--
+-- Defensive pre-clean: should any duplicate live COACH rows already exist
+-- (created before this constraint landed), keep the most recently created one
+-- and tombstone the rest so the unique index can be built without error.
+WITH ranked AS (
+ SELECT
+ id,
+ ROW_NUMBER() OVER (
+ PARTITION BY user_id, measurement_type
+ ORDER BY created_at DESC, id DESC
+ ) AS rn
+ FROM "measurement_reminders"
+ WHERE origin = 'COACH' AND deleted_at IS NULL
+)
+UPDATE "measurement_reminders" m
+SET deleted_at = NOW()
+FROM ranked
+WHERE m.id = ranked.id AND ranked.rn > 1;
+
+CREATE UNIQUE INDEX "measurement_reminders_coach_metric_live_key"
+ ON "measurement_reminders" ("user_id", "measurement_type")
+ WHERE origin = 'COACH' AND deleted_at IS NULL;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 6c07d244d..a69270678 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -588,6 +588,14 @@ model User {
// that pairs with the Vorsorge "annual blood panel" reminder so a result
// has somewhere to land. Soft-deleted.
labResults LabResult[]
+ // v1.19.0 — user-scoped biomarker catalog (canonical name/unit/range per
+ // marker); LabResult rows link to a Biomarker for the resolved range/unit.
+ biomarkers Biomarker[]
+ // v1.19.0 — illness / condition journal (born-gated by the module system).
+ // IllnessEpisode is the soft-deleted episode row; IllnessDayLog is the
+ // per-day timeline mirroring the CycleDayLog shape.
+ illnessEpisodes IllnessEpisode[]
+ illnessDayLogs IllnessDayLog[]
// v1.7.0 profile — optional patient-identity fields surfaced on the
// health-record export cover (PDF) and the FHIR `Patient` resource.
@@ -3255,6 +3263,19 @@ model MeasurementReminder {
/// First-due anchor; the resolver falls back to `lastSatisfiedAt ?? createdAt`
/// when NULL.
anchorDate DateTime? @map("anchor_date")
+ /// Optional course-window end. NULL = open-ended cadence (the existing
+ /// behaviour). Non-NULL bounds a finite cadence: the recurrence engine
+ /// (`medication.endsOn` precedent) stops producing occurrences past this
+ /// instant, so a Coach-suggested "measure BP morning+evening for a week"
+ /// self-expires. Additive; every existing reminder keeps `NULL` and stays
+ /// open-ended.
+ endsOn DateTime? @map("ends_on")
+ /// Who produced this reminder. `VORSORGE` = the user created it through the
+ /// Vorsorge surface (the existing, default behaviour). `COACH` = the AI
+ /// coach minted it from an evidence-based cadence suggestion, posted to the
+ /// SAME `POST /api/measurement-reminders` endpoint — one engine, not two.
+ /// Lets the UI label provenance and lets Coach dedupe its own suggestions.
+ origin ReminderOrigin @default(VORSORGE)
/// Local hour (0–23) to fire the nudge, mirroring the mood-reminder hour.
/// DST-safe windowing is applied at the cron via the existing TZ helpers.
notifyHour Int @default(9) @map("notify_hour")
@@ -3277,9 +3298,24 @@ model MeasurementReminder {
@@index([userId, deletedAt])
@@index([userId, nextDueAt])
+ // v1.18.1 — structural accept-dedup for COACH-minted reminders: at most
+ // one LIVE COACH reminder per (user, metric). Enforced as a PARTIAL unique
+ // index (`WHERE origin = 'COACH' AND deleted_at IS NULL`) created by the
+ // 0172 migration — Prisma's schema DSL cannot express a filtered unique
+ // index, so the migration owns the constraint and this is a pointer to it.
@@map("measurement_reminders")
}
+/// Provenance of a MeasurementReminder. `VORSORGE` = user-created through the
+/// Vorsorge surface (default); `COACH` = minted by the AI coach from a cadence
+/// suggestion. Both feed the same engine / endpoint / cron.
+enum ReminderOrigin {
+ VORSORGE
+ COACH
+
+ @@map("reminder_origin")
+}
+
// ─── Lab results ─────────────────────────────────────────────
/// v1.17.1 — minimal structured lab store pairing with the Vorsorge "annual
@@ -3304,6 +3340,14 @@ model LabResult {
/// When the sample was taken (UTC instant; wall-clock anchoring is the
/// reader's concern per the schema's UTC-only convention).
takenAt DateTime @map("taken_at")
+ /// v1.19.0 — optional link to the user-scoped Biomarker catalog. NULL on
+ /// every existing row; a backfill job grouping by `lower(analyte)` will
+ /// populate it later (this migration only adds the column + relation). Once
+ /// linked, server reads resolve the canonical unit + reference bounds from
+ /// the Biomarker, ending the per-row free-text divergence ("LDL" vs "ldl"
+ /// vs "LDL-C", `mg/dL` vs `mg/dl`). The legacy `analyte` / `unit` /
+ /// `reference*` columns stay as historical truth for one release.
+ biomarkerId String? @map("biomarker_id")
/// Provenance: "MANUAL" today; import paths set their own source string.
source String @default("MANUAL")
/// Optional free-text note, AES-256-GCM encrypted at rest via
@@ -3314,13 +3358,55 @@ model LabResult {
/// 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)
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ biomarker Biomarker? @relation(fields: [biomarkerId], references: [id], onDelete: SetNull)
@@index([userId, analyte, takenAt])
@@index([userId, deletedAt])
+ @@index([biomarkerId])
@@map("lab_results")
}
+// ─── Biomarker catalog ───────────────────────────────────────
+
+/// v1.19.0 — user-scoped catalog of blood/lab biomarkers. Defining a marker
+/// once (canonical name, unit, reference bounds, optional context note) and
+/// recording LabResult values against it ends the per-row free-text
+/// divergence the lean v1.17.1 LabResult model invites (re-typing analyte /
+/// unit / both bounds on every reading). The reference range + unit are
+/// authoritative on the catalog row, not on the entry; server reads resolve
+/// from here and the resolved values flow into the DTO (iOS renders, never
+/// recomputes). Unique per user + name so there is never two "LDL"
+/// definitions.
+model Biomarker {
+ id String @id @default(cuid())
+ userId String @map("user_id")
+ /// Canonical display name, e.g. "LDL Cholesterol".
+ name String
+ /// Canonical unit for every reading against this marker, e.g. "mg/dL".
+ unit String
+ /// Optional reference-range bounds. The lab/sex/age-dependent "normal"
+ /// window; the server's range verdict is computed against these.
+ lowerBound Float? @map("lower_bound")
+ upperBound Float? @map("upper_bound")
+ /// Optional per-marker context ("what this means"), AES-256-GCM encrypted
+ /// at rest via `src/lib/crypto.ts` (Bytes column, `*Encrypted` suffix per
+ /// convention).
+ contextEncrypted Bytes? @map("context_encrypted")
+ /// Optional panel grouping moved off the per-entry row, e.g.
+ /// "Großes Blutbild". NULL = standalone marker.
+ panel String?
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @updatedAt @map("updated_at")
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ results LabResult[]
+
+ @@unique([userId, name])
+ @@index([userId])
+ @@map("biomarkers")
+}
+
// ─── User Achievements ───────────────────────────────────────
model UserAchievement {
@@ -3935,6 +4021,33 @@ enum CervixOpening {
@@map("cervix_opening")
}
+/// Broad condition class for an IllnessEpisode (v1.19.0). Widened beyond an
+/// infection log so the journal covers any condition the user keeps for
+/// themselves.
+enum IllnessType {
+ INFECTION
+ ALLERGY
+ INJURY
+ MENTAL_HEALTH
+ AUTOIMMUNE
+ CHRONIC
+ OTHER
+
+ @@map("illness_type")
+}
+
+/// Where an IllnessEpisode sits in its lifecycle (v1.19.0). `CHRONIC_ONGOING`
+/// has no "recovered" date and is excluded from the recovery-gap math;
+/// `FLARE` rows hang off a parent condition via `parentConditionId`.
+enum IllnessLifecycle {
+ ACUTE
+ CHRONIC_ONGOING
+ RECURRING
+ FLARE
+
+ @@map("illness_lifecycle")
+}
+
/// Which method produced a cached CyclePrediction. `CALENDAR` is the
/// median+MAD variability predictor; `SYMPTOTHERMAL` folds the
/// sensiplan BBT + mucus confirmation; `TEMPERATURE_TREND` is the
@@ -4227,6 +4340,141 @@ model CycleSymptomLink {
@@map("cycle_symptom_links")
}
+// ─── Illness / condition journal (v1.19.0) ────────────────────
+//
+// A CONDITION journal, not a flu log: any illness/condition kept for
+// oneself, with a retrospective lens ("how did it announce itself?",
+// what dropped, recovery time). Retrospective + correlated, NEVER a
+// predictor/diagnoser. Born-gated by the module system (the gate is
+// app-layer; there is no enable flag in the schema). The daily timeline
+// mirrors the Cycle three-table shape (DayLog + Symptom catalog + Link).
+
+/// One illness/condition episode. `lifecycle` distinguishes an acute
+/// bout from an ongoing chronic condition, a recurring one, or a flare
+/// hanging off a parent condition (`parentConditionId`). CHRONIC_ONGOING
+/// has no "recovered" date and is excluded from the recovery-gap math.
+model IllnessEpisode {
+ id String @id @default(cuid())
+ userId String @map("user_id")
+ /// User-facing label for the episode, e.g. "Erkältung", "Heuschnupfen".
+ label String
+ /// Broad condition class. Widened beyond an infection log to cover the
+ /// condition journal's scope.
+ type IllnessType
+ /// Where this episode sits in its lifecycle. FLARE rows reference a
+ /// parent condition via `parentConditionId`.
+ lifecycle IllnessLifecycle @default(ACUTE)
+ /// When the episode began (UTC instant; wall-clock anchoring is the
+ /// reader's concern per the schema's UTC-only convention).
+ onsetAt DateTime @map("onset_at")
+ /// When the episode resolved. NULL = still ongoing. CHRONIC_ONGOING
+ /// episodes stay NULL by design.
+ resolvedAt DateTime? @map("resolved_at")
+ /// Self-FK for flares: a FLARE/RECURRING bout hangs off the parent
+ /// condition episode so the history threads under one condition.
+ parentConditionId String? @map("parent_condition_id")
+ /// Optional free-text note, AES-256-GCM encrypted at rest via
+ /// `src/lib/crypto.ts` (Bytes column, `*Encrypted` suffix per
+ /// convention).
+ noteEncrypted Bytes? @map("note_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)
+ /// Parent condition for a flare/recurrence (self-relation).
+ parent IllnessEpisode? @relation("IllnessFlares", fields: [parentConditionId], references: [id], onDelete: SetNull)
+ flares IllnessEpisode[] @relation("IllnessFlares")
+ dayLogs IllnessDayLog[]
+
+ @@index([userId, deletedAt])
+ @@index([userId, onsetAt])
+ @@index([parentConditionId])
+ @@map("illness_episodes")
+}
+
+/// One row per day of an illness episode (mirrors CycleDayLog): the
+/// day's free-text note (encrypted), a coarse functional-impact slider,
+/// an optional fever reading, plus the symptom links. Soft-deleted.
+model IllnessDayLog {
+ id String @id @default(cuid())
+ userId String @map("user_id")
+ episodeId String @map("episode_id")
+ /// YYYY-MM-DD — anchored to `tz` when non-null, else legacy
+ /// Europe/Berlin (the CycleDayLog / MoodEntry date convention).
+ date String
+ /// Coarse self-rated functional impact for the day (0 = no impact /
+ /// fully functional … 3 = bedbound). NULL = not logged. A slider, not a
+ /// symptom — kept on the day row like CycleDayLog's per-day signs.
+ functionalImpact Int? @map("functional_impact")
+ /// Optional body temperature for the day, in Celsius. Queryable
+ /// plaintext (the CycleDayLog.basalBodyTempC precedent): the fever
+ /// signal feeds the retrospective curve. NULL = not measured.
+ feverC Float? @map("fever_c")
+ /// AES-256-GCM ciphertext envelope of the day's free-text note (always
+ /// encrypted — the `*Encrypted` Bytes convention).
+ noteEncrypted Bytes? @map("note_encrypted")
+ /// Optional timezone the `date` is anchored to (the CycleDayLog
+ /// precedent).
+ tz String?
+ 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)
+ episode IllnessEpisode @relation(fields: [episodeId], references: [id], onDelete: Cascade)
+ symptomLinks IllnessSymptomLink[]
+
+ @@unique([episodeId, date])
+ @@index([userId, date])
+ @@index([episodeId])
+ @@map("illness_day_logs")
+}
+
+/// Seeded catalog of illness symptoms (Jackson / WURSS-derived). Mirrors
+/// CycleSymptom: a global, icon-bearing, labelKey-i18n, isActive/sortOrder
+/// catalog joined per day. Severity is captured 0–3 on the link.
+model IllnessSymptom {
+ id String @id @default(cuid())
+ /// Stable machine key (e.g. `cough`). Unique across the catalog so the
+ /// seed migration can upsert idempotently.
+ key String @unique
+ /// i18n message key for the symptom label (`illness.symptom.`).
+ labelKey String @map("label_key")
+ /// Lucide icon name for the symptom chip.
+ icon String?
+ sortOrder Int @default(0) @map("sort_order")
+ /// Soft-hide a symptom without deleting its historical links.
+ isActive Boolean @default(true) @map("is_active")
+
+ links IllnessSymptomLink[]
+
+ @@index([sortOrder])
+ @@map("illness_symptoms")
+}
+
+/// Join: which symptoms an illness day-log carries, with a 0–3 severity.
+/// Composite PK keeps the pairing unique; cascade on both sides so
+/// deleting a day-log (or a catalog symptom) cleans up its links.
+model IllnessSymptomLink {
+ dayLogId String @map("day_log_id")
+ symptomId String @map("symptom_id")
+ /// 0–3 severity for this symptom on this day (the Jackson/WURSS scale:
+ /// 0 = none, 1 = mild, 2 = moderate, 3 = severe). NULL = a plain
+ /// presence link.
+ severity Int? @map("severity")
+ createdAt DateTime @default(now()) @map("created_at")
+
+ dayLog IllnessDayLog @relation(fields: [dayLogId], references: [id], onDelete: Cascade)
+ symptom IllnessSymptom @relation(fields: [symptomId], references: [id], onDelete: Cascade)
+
+ @@id([dayLogId, symptomId])
+ @@index([symptomId])
+ @@map("illness_symptom_links")
+}
+
/// 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.
diff --git a/public/sw.js b/public/sw.js
index 463bc62e2..86e4cbc41 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.18.0";
+ /* @sw-version-fallback */ "v1.18.1";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
const MAX_STATIC_ENTRIES = 150;
diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts
index a2eefaa4f..4bf6d1c82 100644
--- a/scripts/rotate-encryption-key.ts
+++ b/scripts/rotate-encryption-key.ts
@@ -5,6 +5,15 @@
* active key (`ENCRYPTION_ACTIVE_KEY_ID`). Idempotent: rows whose ciphertext
* already carries the active key id prefix are skipped.
*
+ * The set of columns this script rotates is the canonical registry in
+ * `src/lib/crypto/encrypted-columns.ts`. The guard test
+ * `src/lib/crypto/__tests__/encrypted-columns.test.ts` fails CI if any
+ * encrypted column in `prisma/schema.prisma` is missing from the registry,
+ * or if any registry column is not referenced here — so a new `*Encrypted`
+ * column can never silently skip rotation (which would make those rows
+ * permanently undecryptable once the legacy key is dropped, since decrypt is
+ * fail-closed).
+ *
* Usage:
* ENCRYPTION_KEYS='{"v1":"","v2":""}' \
* ENCRYPTION_ACTIVE_KEY_ID=v2 \
@@ -50,13 +59,25 @@ function shouldRotate(value: string | null): boolean {
return id !== getActiveKeyId();
}
-async function rotateField(
+/**
+ * Rotate one `String` ciphertext column on a Prisma model. `delegate` is the
+ * `prisma.` accessor; `field` is the column. Reads only `id` + the
+ * column, re-encrypts every row not already on the active key.
+ */
+async function rotateStringColumn(
table: string,
field: string,
- rows: T[],
- fieldGetter: (row: T) => string | null,
- updater: (id: string, ciphertext: string) => Promise,
+ delegate: {
+ findMany: (args: {
+ select: Record;
+ }) => Promise>>;
+ update: (args: {
+ where: { id: string };
+ data: Record;
+ }) => Promise;
+ },
): Promise {
+ const rows = await delegate.findMany({ select: { id: true, [field]: true } });
const result: RotationResult = {
table,
field,
@@ -65,17 +86,63 @@ async function rotateField(
errors: 0,
};
for (const row of rows) {
- const v = fieldGetter(row);
+ const v = row[field] as string | null;
if (!shouldRotate(v)) continue;
+ const id = row.id as string;
try {
const re = encrypt(decrypt(v as string));
- await updater(row.id, re);
+ await delegate.update({ where: { id }, data: { [field]: re } });
result.rotated++;
} catch (err) {
result.errors++;
- console.error(
- `[${table}.${field}] row ${row.id}: ${(err as Error).message}`,
- );
+ console.error(`[${table}.${field}] row ${id}: ${(err as Error).message}`);
+ }
+ }
+ return result;
+}
+
+/**
+ * Rotate one `Bytes` ciphertext column. The encrypt/decrypt helpers operate
+ * on strings, so we go through a UTF-8 Buffer round-trip identical to the
+ * persistence layer (`src/lib/ai/coach/persistence.ts:60-71`).
+ */
+async function rotateBytesColumn(
+ table: string,
+ field: string,
+ delegate: {
+ findMany: (args: {
+ select: Record;
+ }) => Promise>>;
+ update: (args: {
+ where: { id: string };
+ data: Record;
+ }) => Promise;
+ },
+): Promise {
+ const rows = await delegate.findMany({ select: { id: true, [field]: true } });
+ const result: RotationResult = {
+ table,
+ field,
+ scanned: rows.length,
+ rotated: 0,
+ errors: 0,
+ };
+ for (const row of rows) {
+ const buf = row[field] as Uint8Array | null;
+ if (!buf || buf.byteLength === 0) continue;
+ const asString = Buffer.from(buf).toString("utf8");
+ if (!shouldRotate(asString)) continue;
+ const id = row.id as string;
+ try {
+ const rotated = encrypt(decrypt(asString));
+ const encoded = Buffer.from(rotated, "utf8");
+ const next = new Uint8Array(new ArrayBuffer(encoded.byteLength));
+ next.set(encoded);
+ await delegate.update({ where: { id }, data: { [field]: next } });
+ result.rotated++;
+ } catch (err) {
+ result.errors++;
+ console.error(`[${table}.${field}] row ${id}: ${(err as Error).message}`);
}
}
return result;
@@ -85,36 +152,24 @@ async function main() {
console.log(`Rotating encrypted rows to active key id: ${targetKeyId}`);
const results: RotationResult[] = [];
- // ───── User table ─────
- const users = await prisma.user.findMany({
- select: {
- id: true,
- codexAccessTokenEncrypted: true,
- codexRefreshTokenEncrypted: true,
- aiAnthropicKeyEncrypted: true,
- aiLocalKeyEncrypted: true,
- aiOpenaiKeyEncrypted: true,
- moodLogWebhookSecret: true,
- telegramBotToken: true,
- moodLogUrlEncrypted: true,
- moodLogApiKeyEncrypted: true,
- withingsClientIdEncrypted: true,
- withingsClientSecretEncrypted: true,
- whoopClientIdEncrypted: true,
- whoopClientSecretEncrypted: true,
- fitbitClientIdEncrypted: true,
- fitbitClientSecretEncrypted: true,
- },
- });
-
- const userFields: Array = [
+ // ───── User — integration credentials + AI keys + KVNR (String) ─────
+ // Columns: "codexAccessTokenEncrypted" "codexRefreshTokenEncrypted"
+ // "telegramBotToken" "moodLogWebhookSecret" "moodLogUrlEncrypted"
+ // "moodLogApiKeyEncrypted" "withingsClientIdEncrypted"
+ // "withingsClientSecretEncrypted" "whoopClientIdEncrypted"
+ // "whoopClientSecretEncrypted" "fitbitClientIdEncrypted"
+ // "fitbitClientSecretEncrypted" "nightscoutUrlEncrypted"
+ // "nightscoutTokenEncrypted" "polarAccessTokenEncrypted"
+ // "polarUserIdEncrypted" "polarClientIdEncrypted"
+ // "polarClientSecretEncrypted" "ouraAccessTokenEncrypted"
+ // "ouraRefreshTokenEncrypted" "ouraClientIdEncrypted"
+ // "ouraClientSecretEncrypted" "aiAnthropicKeyEncrypted"
+ // "aiLocalKeyEncrypted" "aiOpenaiKeyEncrypted" "insuranceNumberEncrypted"
+ const userFields = [
"codexAccessTokenEncrypted",
"codexRefreshTokenEncrypted",
- "aiAnthropicKeyEncrypted",
- "aiLocalKeyEncrypted",
- "aiOpenaiKeyEncrypted",
- "moodLogWebhookSecret",
"telegramBotToken",
+ "moodLogWebhookSecret",
"moodLogUrlEncrypted",
"moodLogApiKeyEncrypted",
"withingsClientIdEncrypted",
@@ -123,338 +178,197 @@ async function main() {
"whoopClientSecretEncrypted",
"fitbitClientIdEncrypted",
"fitbitClientSecretEncrypted",
+ "nightscoutUrlEncrypted",
+ "nightscoutTokenEncrypted",
+ "polarAccessTokenEncrypted",
+ "polarUserIdEncrypted",
+ "polarClientIdEncrypted",
+ "polarClientSecretEncrypted",
+ "ouraAccessTokenEncrypted",
+ "ouraRefreshTokenEncrypted",
+ "ouraClientIdEncrypted",
+ "ouraClientSecretEncrypted",
+ "aiAnthropicKeyEncrypted",
+ "aiLocalKeyEncrypted",
+ "aiOpenaiKeyEncrypted",
+ "insuranceNumberEncrypted",
];
for (const field of userFields) {
- const r = await rotateField(
- "User",
- field as string,
- users,
- (u) => (u as Record)[field as string] as string | null,
- async (id, ciphertext) => {
- await prisma.user.update({
- where: { id },
- data: { [field]: ciphertext } as Record,
- });
- },
- );
- results.push(r);
+ results.push(await rotateStringColumn("User", field, prisma.user));
}
- // ───── WithingsConnection table (accessToken / refreshToken) ─────
- const withings = await prisma.withingsConnection.findMany({
- select: { id: true, accessToken: true, refreshToken: true },
- });
- for (const field of ["accessToken", "refreshToken"] as const) {
- const r = await rotateField(
- "WithingsConnection",
- field,
- withings,
- (w) => w[field],
- async (id, ciphertext) => {
- await prisma.withingsConnection.update({
- where: { id },
- data: { [field]: ciphertext } as Record,
- });
- },
- );
- results.push(r);
- }
-
- // ───── WhoopConnection table (accessToken / refreshToken) ─────
- const whoop = await prisma.whoopConnection.findMany({
- select: { id: true, accessToken: true, refreshToken: true },
- });
- for (const field of ["accessToken", "refreshToken"] as const) {
- const r = await rotateField(
- "WhoopConnection",
- field,
- whoop,
- (w) => w[field],
- async (id, ciphertext) => {
- await prisma.whoopConnection.update({
- where: { id },
- data: { [field]: ciphertext } as Record,
- });
- },
- );
- results.push(r);
- }
-
- // ───── FitbitConnection table (accessToken / refreshToken) ─────
- const fitbit = await prisma.fitbitConnection.findMany({
- select: { id: true, accessToken: true, refreshToken: true },
- });
- for (const field of ["accessToken", "refreshToken"] as const) {
- const r = await rotateField(
- "FitbitConnection",
- field,
- fitbit,
- (f) => f[field],
- async (id, ciphertext) => {
- await prisma.fitbitConnection.update({
- where: { id },
- data: { [field]: ciphertext } as Record,
- });
- },
- );
- results.push(r);
- }
-
- // ───── AppSettings table (singleton typically) ─────
- const settings = await prisma.appSettings.findMany({
- select: {
- id: true,
- adminAiKeyEncrypted: true,
- webPushVapidPrivateKeyEncrypted: true,
- githubIssueTokenEncrypted: true,
- },
- });
- const appFields = [
- "adminAiKeyEncrypted",
- "webPushVapidPrivateKeyEncrypted",
- "githubIssueTokenEncrypted",
- ] as const;
- for (const field of appFields) {
- const r = await rotateField(
- "AppSettings",
- field,
- settings,
- (s) => s[field],
- async (id, ciphertext) => {
- await prisma.appSettings.update({
- where: { id },
- data: { [field]: ciphertext } as Record,
- });
- },
- );
- results.push(r);
- }
-
- // ───── MoodTag.labelEncrypted ─────
- // v1.13.0 — AES-256-GCM ciphertext of a custom mood tag's free-text
- // label (catalogue rows carry NULL and are skipped by `shouldRotate`).
- const moodTags = await prisma.moodTag.findMany({
- where: { labelEncrypted: { not: null } },
- select: { id: true, labelEncrypted: true },
- });
- results.push(
- await rotateField(
- "MoodTag",
- "labelEncrypted",
- moodTags,
- (t) => t.labelEncrypted,
- async (id, ciphertext) => {
- await prisma.moodTag.update({
- where: { id },
- data: { labelEncrypted: ciphertext },
- });
- },
- ),
- );
-
- // ───── MoodTagCategory.labelEncrypted ─────
- // v1.17.0 — AES-256-GCM ciphertext of a custom mood-tag group's
- // free-text label (seeded rows carry NULL and are skipped).
- const moodTagCategories = await prisma.moodTagCategory.findMany({
- where: { labelEncrypted: { not: null } },
- select: { id: true, labelEncrypted: true },
- });
- results.push(
- await rotateField(
- "MoodTagCategory",
- "labelEncrypted",
- moodTagCategories,
- (c) => c.labelEncrypted,
- async (id, ciphertext) => {
- await prisma.moodTagCategory.update({
- where: { id },
- data: { labelEncrypted: ciphertext },
- });
- },
- ),
- );
-
- // ───── NotificationChannel.config ─────
- // Channel config (Telegram chat id, ntfy topic, etc.) is encrypted JSON.
- // Skipping these on rotation would leave channels permanently undecryptable
- // once the operator drops `v1` from the key map.
- const channels = await prisma.notificationChannel.findMany({
- select: { id: true, config: true },
- });
- results.push(
- await rotateField(
- "NotificationChannel",
- "config",
- channels,
- (c) => c.config,
- async (id, ciphertext) => {
- await prisma.notificationChannel.update({
- where: { id },
- data: { config: ciphertext },
- });
- },
- ),
- );
-
- // ───── PushSubscription.{p256dh, auth} ─────
- // Web-push routing secrets — without these, the push endpoint is reachable
- // but the browser ignores the message (auth tag mismatch).
- const subs = await prisma.pushSubscription.findMany({
- select: { id: true, p256dh: true, auth: true },
- });
- for (const field of ["p256dh", "auth"] as const) {
+ // ───── OAuth token tables (String "accessToken" / "refreshToken") ─────
+ for (const field of ["accessToken", "refreshToken"]) {
results.push(
- await rotateField(
- "PushSubscription",
+ await rotateStringColumn(
+ "WithingsConnection",
field,
- subs,
- (s) => s[field],
- async (id, ciphertext) => {
- await prisma.pushSubscription.update({
- where: { id },
- data: { [field]: ciphertext } as Record,
- });
- },
+ prisma.withingsConnection,
+ ),
+ );
+ results.push(
+ await rotateStringColumn("WhoopConnection", field, prisma.whoopConnection),
+ );
+ results.push(
+ await rotateStringColumn(
+ "FitbitConnection",
+ field,
+ prisma.fitbitConnection,
),
);
}
- // ───── IntegrationStatus.lastError ─────
- // Per `prisma/schema.prisma:1441` — AES-256-GCM ciphertext of an
- // upstream error payload. Skipped historically; drop the legacy
- // key while a row still lives here and the admin status view 500s.
- const statuses = await prisma.integrationStatus.findMany({
- select: { id: true, lastError: true },
- });
+ // ───── AppSettings — operator credentials (String) ─────
+ // Columns: "adminAiKeyEncrypted" "webPushVapidPrivateKeyEncrypted"
+ // "githubIssueTokenEncrypted"
+ for (const field of [
+ "adminAiKeyEncrypted",
+ "webPushVapidPrivateKeyEncrypted",
+ "githubIssueTokenEncrypted",
+ ]) {
+ results.push(
+ await rotateStringColumn("AppSettings", field, prisma.appSettings),
+ );
+ }
+
+ // ───── Custom labels — mood + cycle (String "labelEncrypted") ─────
+ // Catalogue rows carry NULL and are skipped by `shouldRotate`.
results.push(
- await rotateField(
- "IntegrationStatus",
- "lastError",
- statuses,
- (s) => s.lastError,
- async (id, ciphertext) => {
- await prisma.integrationStatus.update({
- where: { id },
- data: { lastError: ciphertext },
- });
- },
+ await rotateStringColumn("MoodTag", "labelEncrypted", prisma.moodTag),
+ );
+ results.push(
+ await rotateStringColumn(
+ "MoodTagCategory",
+ "labelEncrypted",
+ prisma.moodTagCategory,
+ ),
+ );
+ results.push(
+ await rotateStringColumn(
+ "CycleSymptom",
+ "labelEncrypted",
+ prisma.cycleSymptom,
),
);
- // ───── CoachMessage.encryptedContent ─────
- // Per `prisma/schema.prisma:1983` — `Bytes` column carrying the
- // UTF-8 ciphertext of every persisted Coach message. The encrypt /
- // decrypt helpers operate on strings, so we go through a Buffer
- // round-trip identical to `src/lib/ai/coach/persistence.ts:60-71`.
- const coachMessages = await prisma.coachMessage.findMany({
- select: { id: true, encryptedContent: true },
- });
- const coachResult: RotationResult = {
- table: "CoachMessage",
- field: "encryptedContent",
- scanned: coachMessages.length,
- rotated: 0,
- errors: 0,
- };
- for (const row of coachMessages) {
- const buf = row.encryptedContent;
- if (!buf || buf.byteLength === 0) continue;
- const asString = Buffer.from(buf).toString("utf8");
- if (!shouldRotate(asString)) continue;
- try {
- const rotated = encrypt(decrypt(asString));
- const encoded = Buffer.from(rotated, "utf8");
- const next = new Uint8Array(new ArrayBuffer(encoded.byteLength));
- next.set(encoded);
- await prisma.coachMessage.update({
- where: { id: row.id },
- data: { encryptedContent: next },
- });
- coachResult.rotated++;
- } catch (err) {
- coachResult.errors++;
- console.error(
- `[CoachMessage.encryptedContent] row ${row.id}: ${(err as Error).message}`,
- );
- }
- }
- results.push(coachResult);
+ // ───── NotificationChannel."config" (encrypted JSON) ─────
+ // Channel config (Telegram chat id, ntfy topic, etc.). Skipping these on
+ // rotation would leave channels permanently undecryptable once the
+ // operator drops `v1` from the key map.
+ results.push(
+ await rotateStringColumn(
+ "NotificationChannel",
+ "config",
+ prisma.notificationChannel,
+ ),
+ );
- // ───── CoachConversation.summaryEncrypted ─────
- // v1.11.1 — `Bytes` column carrying the UTF-8 ciphertext of the rolling
- // conversation summary. Same Buffer round-trip as CoachMessage; nullable, so
- // skip empty/absent rows.
- const coachConversations = await prisma.coachConversation.findMany({
- where: { summaryEncrypted: { not: null } },
- select: { id: true, summaryEncrypted: true },
- });
- const summaryResult: RotationResult = {
- table: "CoachConversation",
- field: "summaryEncrypted",
- scanned: coachConversations.length,
- rotated: 0,
- errors: 0,
- };
- for (const row of coachConversations) {
- const buf = row.summaryEncrypted;
- if (!buf || buf.byteLength === 0) continue;
- const asString = Buffer.from(buf).toString("utf8");
- if (!shouldRotate(asString)) continue;
- try {
- const rotated = encrypt(decrypt(asString));
- const encoded = Buffer.from(rotated, "utf8");
- const next = new Uint8Array(new ArrayBuffer(encoded.byteLength));
- next.set(encoded);
- await prisma.coachConversation.update({
- where: { id: row.id },
- data: { summaryEncrypted: next },
- });
- summaryResult.rotated++;
- } catch (err) {
- summaryResult.errors++;
- console.error(
- `[CoachConversation.summaryEncrypted] row ${row.id}: ${(err as Error).message}`,
- );
- }
+ // ───── PushSubscription."p256dh" / "auth" ─────
+ // Web-push routing secrets — without these, the push endpoint is reachable
+ // but the browser ignores the message (auth tag mismatch).
+ for (const field of ["p256dh", "auth"]) {
+ results.push(
+ await rotateStringColumn(
+ "PushSubscription",
+ field,
+ prisma.pushSubscription,
+ ),
+ );
}
- results.push(summaryResult);
- // ───── CoachFact.factEncrypted ─────
- // v1.11.1 — `Bytes` column carrying the UTF-8 ciphertext of each durable
- // personal fact. Same Buffer round-trip as CoachMessage.
- const coachFacts = await prisma.coachFact.findMany({
- select: { id: true, factEncrypted: true },
- });
- const factResult: RotationResult = {
- table: "CoachFact",
- field: "factEncrypted",
- scanned: coachFacts.length,
- rotated: 0,
- errors: 0,
- };
- for (const row of coachFacts) {
- const buf = row.factEncrypted;
- if (!buf || buf.byteLength === 0) continue;
- const asString = Buffer.from(buf).toString("utf8");
- if (!shouldRotate(asString)) continue;
- try {
- const rotated = encrypt(decrypt(asString));
- const encoded = Buffer.from(rotated, "utf8");
- const next = new Uint8Array(new ArrayBuffer(encoded.byteLength));
- next.set(encoded);
- await prisma.coachFact.update({
- where: { id: row.id },
- data: { factEncrypted: next },
- });
- factResult.rotated++;
- } catch (err) {
- factResult.errors++;
- console.error(
- `[CoachFact.factEncrypted] row ${row.id}: ${(err as Error).message}`,
- );
- }
+ // ───── IntegrationStatus."lastError" ─────
+ // AES-256-GCM ciphertext of an upstream error payload. Drop the legacy key
+ // while a row still lives here and the admin status view 500s.
+ results.push(
+ await rotateStringColumn(
+ "IntegrationStatus",
+ "lastError",
+ prisma.integrationStatus,
+ ),
+ );
+
+ // ───── CycleDayLog — "sensitiveEncrypted" / "notesEncrypted" (String) ─────
+ for (const field of ["sensitiveEncrypted", "notesEncrypted"]) {
+ results.push(
+ await rotateStringColumn("CycleDayLog", field, prisma.cycleDayLog),
+ );
}
- results.push(factResult);
+
+ // ───── Coach (Bytes columns) ─────
+ // "encryptedContent" "summaryEncrypted" "factEncrypted"
+ results.push(
+ await rotateBytesColumn(
+ "CoachMessage",
+ "encryptedContent",
+ prisma.coachMessage,
+ ),
+ );
+ results.push(
+ await rotateBytesColumn(
+ "CoachConversation",
+ "summaryEncrypted",
+ prisma.coachConversation,
+ ),
+ );
+ results.push(
+ await rotateBytesColumn("CoachFact", "factEncrypted", prisma.coachFact),
+ );
+
+ // ───── UserHealthProfile (Bytes columns) ─────
+ // "aboutMeEncrypted" "conditionsEncrypted" "allergiesEncrypted"
+ // "coachFocusEncrypted" "pendingQuestionsEncrypted"
+ for (const field of [
+ "aboutMeEncrypted",
+ "conditionsEncrypted",
+ "allergiesEncrypted",
+ "coachFocusEncrypted",
+ "pendingQuestionsEncrypted",
+ ]) {
+ results.push(
+ await rotateBytesColumn(
+ "UserHealthProfile",
+ field,
+ prisma.userHealthProfile,
+ ),
+ );
+ }
+
+ // ───── InsightNarrative."encryptedContent" (Bytes) ─────
+ results.push(
+ await rotateBytesColumn(
+ "InsightNarrative",
+ "encryptedContent",
+ prisma.insightNarrative,
+ ),
+ );
+
+ // ───── v1.18.1 clinical-spine notes (Bytes columns) ─────
+ // "noteEncrypted" (LabResult / IllnessEpisode / IllnessDayLog) +
+ // "contextEncrypted" (Biomarker). Mirror the CoachFact.factEncrypted block.
+ results.push(
+ await rotateBytesColumn("LabResult", "noteEncrypted", prisma.labResult),
+ );
+ results.push(
+ await rotateBytesColumn(
+ "Biomarker",
+ "contextEncrypted",
+ prisma.biomarker,
+ ),
+ );
+ results.push(
+ await rotateBytesColumn(
+ "IllnessEpisode",
+ "noteEncrypted",
+ prisma.illnessEpisode,
+ ),
+ );
+ results.push(
+ await rotateBytesColumn(
+ "IllnessDayLog",
+ "noteEncrypted",
+ prisma.illnessDayLog,
+ ),
+ );
console.log("\n=== Rotation summary ===");
let totalRotated = 0;
diff --git a/src/app/admin/[section]/renderer.tsx b/src/app/admin/[section]/renderer.tsx
index d1e4db387..8bd9e34be 100644
--- a/src/app/admin/[section]/renderer.tsx
+++ b/src/app/admin/[section]/renderer.tsx
@@ -90,33 +90,20 @@ export function AdminSectionRenderer({
);
- case "ai-quality":
+ case "coach":
+ // v1.18.1 — the former ai-quality, assistant, and coach-feedback
+ // sections fold into one Coach area: surface toggles + operator
+ // config first, then the two feedback-quality tables.
return (
-
-
- );
- case "assistant":
- return (
-
-
- );
- case "coach-feedback":
- return (
-
+
);
case "feedback":
@@ -207,7 +194,9 @@ export function AdminSectionRenderer({
// v1.4.36 W4e — About section reused as-is from the settings
// surface. The component owns its own heading + cards layout
// so no SectionFrame wrapper.
- return ;
+ // v1.18.1 E5 — hide the onboarding "Replay tour" card on the admin
+ // surface; it's a user-facing affordance with no admin-side use.
+ return ;
default:
slug satisfies never;
return null;
diff --git a/src/app/api/__tests__/module-route-gate-inventory.test.ts b/src/app/api/__tests__/module-route-gate-inventory.test.ts
index 91447908d..57a5daca1 100644
--- a/src/app/api/__tests__/module-route-gate-inventory.test.ts
+++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts
@@ -74,8 +74,29 @@ const MODULE_ROUTE_TREES: ReadonlyArray = [
"src/app/api/sleep",
"src/app/api/workouts",
"src/app/api/labs",
+ // v1.18.1 — the user-scoped Biomarker catalog backs the Labs feature.
+ "src/app/api/biomarkers",
"src/app/api/cycle",
+ // v1.18.1 (W-B) — the illness/condition journal. Every `/api/illness/*`
+ // route gates on the born-gated `illness` module via the thin
+ // `requireIllnessEnabled(...)` wrapper (which re-stamps the
+ // illness-specific errorCode over `requireModuleEnabled("illness")`),
+ // recognised below as a delegated gate. Walking the tree means a NEW
+ // ungated illness route fails this test BY NAME rather than leaking the
+ // surface over a Bearer token when the account never opted in.
+ "src/app/api/illness",
"src/app/api/gamification",
+ // v1.18.1 (D3) — medications graduated from CORE to a toggleable module.
+ // It is SURFACE-gated (nav entry, dashboard widget, the dedicated
+ // Medikamente settings entry), not data-layer-gated: every `/api/medications/*`
+ // route is raw medication CRUD / intake / inventory / compliance over the
+ // user's own rows, so they are EXEMPT below under the same data-layer
+ // reasoning as mood/labs — disabling the module hides the surface, it does
+ // not wedge an importer / sync / the user's ability to clean up, and
+ // re-enabling finds the schedule + intake history intact. Walking the tree
+ // means a NEW medication route must justify itself (gate or document-exempt)
+ // rather than silently appearing.
+ "src/app/api/medications",
// v1.18.0 B3 — the legacy `/api/doctor-report` tree (JSON + server-PDF +
// availability probe) was orphaned dead code (no production caller) and
// removed. The live doctor-report / FHIR surface is `/api/export/health-record`,
@@ -129,6 +150,50 @@ const EXEMPT_ROUTES: ReadonlyArray = [
"src/app/api/workouts/batch/route.ts",
"src/app/api/labs/route.ts",
"src/app/api/labs/[id]/route.ts",
+ // v1.18.1 — the lab-result delete-Undo restore endpoint and the
+ // user-scoped Biomarker catalog CRUD share the LabResult data-layer
+ // reasoning: the labs module gates the SURFACES (the Labs page), not the
+ // row store. A synced / pre-existing reading and its catalog definition
+ // must survive a disabled module so re-enabling reveals a complete history.
+ "src/app/api/labs/restore/route.ts",
+ "src/app/api/biomarkers/route.ts",
+ "src/app/api/biomarkers/[id]/route.ts",
+ // ── DATA LAYER (medications) ──────────────────────────────────────
+ // v1.18.1 (D3) — medications graduated from CORE to a toggleable module,
+ // but it is SURFACE-gated (nav / dashboard widget / settings entry), not
+ // data-layer-gated. Every `/api/medications/*` route is raw CRUD over the
+ // user's own medication / intake / inventory / compliance / side-effect
+ // rows — the same data-layer reasoning as mood/labs: the module gate
+ // governs whether medications SURFACES, not whether the row store accepts
+ // writes. An importer / sync / the user's ability to clean up entries must
+ // keep working while the module is off, and re-enabling must find the
+ // schedule + intake history intact.
+ "src/app/api/medications/route.ts",
+ "src/app/api/medications/layout/route.ts",
+ "src/app/api/medications/compliance/route.ts",
+ "src/app/api/medications/intake/route.ts",
+ "src/app/api/medications/intake/bulk/route.ts",
+ // NB: `medications/extract` is NOT exempt — it gates on
+ // `requireAssistantSurface("coach")` (the NL-extraction is an assistant
+ // surface), so the inventory already counts it as a delegated gate.
+ "src/app/api/medications/[id]/route.ts",
+ "src/app/api/medications/[id]/api-endpoint/route.ts",
+ "src/app/api/medications/[id]/cadence/route.ts",
+ "src/app/api/medications/[id]/compliance/route.ts",
+ "src/app/api/medications/[id]/dose-history/route.ts",
+ "src/app/api/medications/[id]/glp1/route.ts",
+ "src/app/api/medications/[id]/phase-config/route.ts",
+ "src/app/api/medications/[id]/intake/route.ts",
+ "src/app/api/medications/[id]/intake/[eventId]/route.ts",
+ "src/app/api/medications/[id]/intake/bulk-delete/route.ts",
+ "src/app/api/medications/[id]/intake/import/route.ts",
+ "src/app/api/medications/[id]/intake/purge/route.ts",
+ "src/app/api/medications/[id]/inventory/route.ts",
+ "src/app/api/medications/[id]/inventory/[itemId]/route.ts",
+ "src/app/api/medications/[id]/schedule-revisions/route.ts",
+ "src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts",
+ "src/app/api/medications/[id]/side-effects/route.ts",
+ "src/app/api/medications/[id]/side-effects/[logId]/route.ts",
// ── INFRA / UI-ONLY ───────────────────────────────────────────────
// Static FHIR CapabilityStatement — server metadata, no user data.
"src/app/api/fhir/metadata/route.ts",
@@ -168,6 +233,12 @@ const EXEMPT_ROUTES: ReadonlyArray = [
const MODULE_GATE_NEEDLE = "requireModuleEnabled(";
const CYCLE_GATE_NEEDLE = "requireCycleEnabled(";
const COACH_GATE_NEEDLE = 'requireAssistantSurface("coach")';
+// v1.18.1 — the illness journal's thin gate wrapper. `requireIllnessEnabled`
+// delegates to `requireModuleEnabled(userId, "illness")` and re-stamps the
+// illness-specific errorCode, exactly mirroring how `cycle` delegates to
+// `requireCycleEnabled`. Recognised as a delegated gate so illness routes
+// are not flagged as ungated.
+const ILLNESS_GATE_NEEDLE = "requireIllnessEnabled(";
// Builder aggregators that resolve `resolveModuleMap` once and exclude
// disabled-module sections/resources at the build boundary.
const BUILDER_GATE_NEEDLES: ReadonlyArray = [
@@ -251,11 +322,13 @@ describe("module API route gate inventory", () => {
"workouts",
"recovery",
"labs",
+ "illness",
"achievements",
"cycle",
"coach",
"doctorReport",
"insights",
+ "medications",
] as const) {
expect(known.has(key), `unknown module key in inventory: ${key}`).toBe(
true,
@@ -276,6 +349,7 @@ describe("module API route gate inventory", () => {
if (fileHasCall(text, MODULE_GATE_NEEDLE)) continue;
if (fileHasCall(text, CYCLE_GATE_NEEDLE)) continue;
if (fileHasCall(text, COACH_GATE_NEEDLE)) continue;
+ if (fileHasCall(text, ILLNESS_GATE_NEEDLE)) continue;
if (BUILDER_GATE_NEEDLES.some((n) => fileHasCall(text, n))) continue;
if (exempt.has(path)) continue;
@@ -346,6 +420,7 @@ describe("module API route gate inventory", () => {
fileHasCall(text, MODULE_GATE_NEEDLE) ||
fileHasCall(text, CYCLE_GATE_NEEDLE) ||
fileHasCall(text, COACH_GATE_NEEDLE) ||
+ fileHasCall(text, ILLNESS_GATE_NEEDLE) ||
BUILDER_GATE_NEEDLES.some((n) => fileHasCall(text, n))
) {
stillGated.push(path);
diff --git a/src/app/api/admin/data/route.ts b/src/app/api/admin/data/route.ts
index 06e90123d..abd69fd15 100644
--- a/src/app/api/admin/data/route.ts
+++ b/src/app/api/admin/data/route.ts
@@ -26,6 +26,22 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
const { user } = await requireAdmin();
annotate({ action: { name: "admin.data.delete" } });
+ // v1.18.1 — the documented convention buckets authenticated admin
+ // mutations on `userId`, not the (pre-auth, spoofable) client IP. The
+ // IP bucket above is the anonymous first-line throttle; this is the
+ // canonical per-admin bucket the rate-limit contract specifies.
+ const userRl = await checkRateLimit(
+ `admin-data-delete:${user.id}`,
+ 5,
+ 60 * 1000,
+ );
+ if (!userRl.allowed) {
+ return NextResponse.json(
+ { data: null, error: "Rate limit exceeded" },
+ { status: 429, headers: rateLimitHeaders(userRl) },
+ );
+ }
+
let confirm = "";
try {
const raw = await request.text();
diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts
index 749a7beed..9539cc1ec 100644
--- a/src/app/api/analytics/route.ts
+++ b/src/app/api/analytics/route.ts
@@ -18,6 +18,7 @@ import {
} from "@/lib/analytics/bp-in-target-fast-path";
import { computeUserHealthScoreFastPath } from "@/lib/analytics/health-score-fast-path";
import { isModuleEnabled } from "@/lib/modules/gate";
+import { resolveRestMode } from "@/lib/illness/rest-mode";
import { buildHealthScoreBpInputs } from "@/lib/analytics/health-score-inputs";
import { deriveBpWindow90 } from "@/lib/analytics/window-confidence";
import { computeCorrelationHypothesesFastPath } from "@/lib/analytics/correlations-fast-path";
@@ -435,15 +436,35 @@ async function buildAnalyticsResponse(user: AuthedUser) {
const glucoseEnabled = await isModuleEnabled(user.id, "glucose");
const glucoseByContextOut = glucoseEnabled ? glucoseByContext : null;
const glucoseClinicalOut = glucoseEnabled ? glucoseClinical : null;
+ // The reference instant the score is graded as-of. Reused below so the Rest
+ // Mode annotation resolves against the SAME window the score covers, not a
+ // fresh "now" that could fall outside it.
+ const scoredAt = new Date();
const healthScore = await computeUserHealthScoreFastPath({
userId: user.id,
...bpInputs,
heightCm: user.heightCm ?? null,
- now: new Date(),
+ now: scoredAt,
coverage,
moodEnabled,
});
+ // v1.18.1 P4 — Rest Mode annotation. When an illness/condition episode is
+ // active we frame the score (not penalise it): the number above is left
+ // exactly as computed and we attach a value-free "you were unwell" context.
+ // Resolved server-side so iOS mirrors the same fact, never recomputing it.
+ // Resolved as-of the scored window (`scoredAt`), not a fresh now.
+ if (healthScore) {
+ const restMode = await resolveRestMode(user.id, scoredAt);
+ healthScore.restMode = restMode.active
+ ? {
+ active: true,
+ since: restMode.since,
+ episodeCount: restMode.episodeCount,
+ }
+ : null;
+ }
+
return {
summaries: results,
bmi,
diff --git a/src/app/api/auth/me/modules/__tests__/route.test.ts b/src/app/api/auth/me/modules/__tests__/route.test.ts
index d7d80ce70..4f5322d44 100644
--- a/src/app/api/auth/me/modules/__tests__/route.test.ts
+++ b/src/app/api/auth/me/modules/__tests__/route.test.ts
@@ -237,7 +237,9 @@ describe("PATCH /api/auth/me/modules", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser();
- for (const core of ["weight", "bloodPressure", "pulse", "medications"]) {
+ // v1.18.1 (D3) — medications graduated to a toggleable module, so only
+ // weight / blood pressure / pulse stay refused as core domains.
+ for (const core of ["weight", "bloodPressure", "pulse"]) {
const res = await (PATCH as (r: Request) => Promise)(
mkPatch({ [core]: false }),
);
diff --git a/src/app/api/auth/me/modules/route.ts b/src/app/api/auth/me/modules/route.ts
index c846f4368..b5f9080ec 100644
--- a/src/app/api/auth/me/modules/route.ts
+++ b/src/app/api/auth/me/modules/route.ts
@@ -12,10 +12,11 @@
*
* The body schema (`modulePrefsPatchSchema`) is `strict()` over the
* directly-owned toggleable key set, so a core-domain key (`weight`,
- * `bloodPressure`, `pulse`, `medications`), a delegated key (`cycle`,
- * `coach`), or any unknown key is a 422 — the core measurement engine +
- * meds can never be disabled here, and a value for a delegated module can
- * never land inert in `modulePreferencesJson`. The delegated modules are
+ * `bloodPressure`, `pulse`), a delegated key (`cycle`, `coach`), or any
+ * unknown key is a 422 — the core measurement engine can never be disabled
+ * here, and a value for a delegated module can never land inert in
+ * `modulePreferencesJson`. v1.18.1 (D3) — `medications` graduated to a
+ * toggleable module, so it IS accepted here now. The delegated modules are
* managed at their real control (cycle in Account, coach in Settings →
* Coach); the Modules hub deep-links there rather than offering a dead
* toggle.
diff --git a/src/app/api/biomarkers/[id]/route.ts b/src/app/api/biomarkers/[id]/route.ts
new file mode 100644
index 000000000..87ec6f236
--- /dev/null
+++ b/src/app/api/biomarkers/[id]/route.ts
@@ -0,0 +1,205 @@
+import { NextRequest } from "next/server";
+
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import {
+ apiError,
+ apiSuccess,
+ getClientIp,
+ returnAllZodIssues,
+ safeJson,
+ sanitiseZodIssues,
+} from "@/lib/api-response";
+import { auditLog } from "@/lib/auth/audit";
+import { prisma } from "@/lib/db";
+import {
+ decryptContextFromBytes,
+ encryptContextToBytes,
+} from "@/lib/labs/biomarker-store";
+import { annotate } from "@/lib/logging/context";
+import { updateBiomarkerSchema } from "@/lib/validations/biomarkers";
+import { effectiveBound, isInvertedRange } from "@/lib/validations/labs";
+
+/**
+ * v1.18.1 — single Biomarker resource (`/api/biomarkers/{id}`).
+ *
+ * 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).
+ */
+
+type RouteParams = { params: Promise<{ id: string }> };
+
+function serialiseBiomarker(row: {
+ id: string;
+ name: string;
+ unit: string;
+ lowerBound: number | null;
+ upperBound: number | null;
+ panel: string | null;
+ contextEncrypted: Uint8Array | null;
+ createdAt: Date;
+ updatedAt: Date;
+}) {
+ return {
+ id: row.id,
+ name: row.name,
+ unit: row.unit,
+ lowerBound: row.lowerBound,
+ upperBound: row.upperBound,
+ panel: row.panel,
+ hasContext: row.contextEncrypted !== null,
+ context: row.contextEncrypted
+ ? decryptContextFromBytes(row.contextEncrypted)
+ : null,
+ createdAt: row.createdAt.toISOString(),
+ updatedAt: row.updatedAt.toISOString(),
+ };
+}
+
+export const GET = apiHandler(
+ async (_request: NextRequest, { params }: RouteParams) => {
+ const { user } = await requireAuth();
+ const { id } = await params;
+
+ const row = await prisma.biomarker.findFirst({ where: { id } });
+ if (!row || row.userId !== user.id) {
+ return apiError("Biomarker not found", 404);
+ }
+
+ annotate({
+ action: { name: "labs.biomarker.get" },
+ meta: { biomarkerId: id },
+ });
+
+ return apiSuccess(serialiseBiomarker(row));
+ },
+);
+
+export const PUT = apiHandler(
+ async (request: NextRequest, { params }: RouteParams) => {
+ const { user } = await requireAuth();
+ const { id } = await params;
+
+ const existing = await prisma.biomarker.findFirst({ where: { id } });
+ if (!existing || existing.userId !== user.id) {
+ return apiError("Biomarker not found", 404);
+ }
+
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 16 * 1024,
+ });
+ if (jsonError) return jsonError;
+
+ const parsed = updateBiomarkerSchema.safeParse(body);
+ if (!parsed.success) {
+ annotate({
+ action: { name: "labs.biomarker.update.validation-failed" },
+ meta: { issue_count: parsed.error.issues.length, biomarkerId: id },
+ });
+ const auditIssues = sanitiseZodIssues(parsed.error.issues, {
+ stripValuesFromMessage: true,
+ });
+ prisma.auditLog
+ .create({
+ data: {
+ userId: user.id,
+ action: "labs.biomarker.update.validation-failed",
+ details: JSON.stringify({ issues: auditIssues, biomarkerId: id }),
+ },
+ })
+ .catch(() => {
+ /* swallow — the 422 response is the contract */
+ });
+ return returnAllZodIssues(parsed.error, 422);
+ }
+
+ const d = parsed.data;
+
+ // A rename must not collide with another of the caller's markers.
+ if (d.name !== undefined && d.name !== existing.name) {
+ const clash = await prisma.biomarker.findFirst({
+ where: { userId: user.id, name: d.name },
+ select: { id: true },
+ });
+ if (clash) {
+ return apiError("A biomarker with this name already exists", 409);
+ }
+ }
+
+ // Build `data` field-by-field. `undefined` → leave the column untouched;
+ // an explicit `null` on `context` / `panel` / a bound clears it.
+ const data: Record = {};
+ if (d.name !== undefined) data.name = d.name;
+ if (d.unit !== undefined) data.unit = d.unit;
+ if (d.lowerBound !== undefined) data.lowerBound = d.lowerBound;
+ if (d.upperBound !== undefined) data.upperBound = d.upperBound;
+ if (d.panel !== undefined) data.panel = d.panel;
+
+ // Inverted-range guard for a PARTIAL bound update. The schema refine only
+ // fires when both bounds arrive together; moving a single bound past the
+ // row's existing other bound would otherwise persist an inverted window.
+ // Merge effective bounds (parsed when present, else stored) and 422 when
+ // both resolve to concrete numbers with low > high.
+ if (
+ isInvertedRange(
+ effectiveBound(d.lowerBound, existing.lowerBound),
+ effectiveBound(d.upperBound, existing.upperBound),
+ )
+ ) {
+ return apiError("lowerBound must not exceed upperBound", 422);
+ }
+ if (d.context !== undefined) {
+ data.contextEncrypted = d.context
+ ? encryptContextToBytes(d.context)
+ : null;
+ }
+
+ const updated = await prisma.biomarker.update({ where: { id }, data });
+
+ await auditLog("biomarker.update", {
+ userId: user.id,
+ ipAddress: getClientIp(request),
+ details: { biomarkerId: id },
+ });
+
+ annotate({
+ action: { name: "labs.biomarker.update" },
+ meta: { biomarkerId: id },
+ });
+
+ return apiSuccess(serialiseBiomarker(updated));
+ },
+);
+
+export const DELETE = apiHandler(
+ async (request: NextRequest, { params }: RouteParams) => {
+ const { user } = await requireAuth();
+ const { id } = await params;
+
+ const existing = await prisma.biomarker.findFirst({ where: { id } });
+ if (!existing || existing.userId !== user.id) {
+ 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 } });
+
+ await auditLog("biomarker.delete", {
+ userId: user.id,
+ ipAddress: getClientIp(request),
+ details: { biomarkerId: id },
+ });
+
+ annotate({
+ action: { name: "labs.biomarker.delete" },
+ meta: { biomarkerId: id },
+ });
+
+ return apiSuccess({ deleted: true });
+ },
+);
diff --git a/src/app/api/biomarkers/route.ts b/src/app/api/biomarkers/route.ts
new file mode 100644
index 000000000..a9c2a7ff5
--- /dev/null
+++ b/src/app/api/biomarkers/route.ts
@@ -0,0 +1,158 @@
+import { NextRequest } from "next/server";
+
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import {
+ apiError,
+ apiSuccess,
+ getClientIp,
+ returnAllZodIssues,
+ safeJson,
+ sanitiseZodIssues,
+} from "@/lib/api-response";
+import { auditLog } from "@/lib/auth/audit";
+import { prisma } from "@/lib/db";
+import { withIdempotency } from "@/lib/idempotency";
+import {
+ decryptContextSoft,
+ encryptContextToBytes,
+} from "@/lib/labs/biomarker-store";
+import { annotate } from "@/lib/logging/context";
+import { createBiomarkerSchema } from "@/lib/validations/biomarkers";
+
+/**
+ * v1.18.1 — user-scoped Biomarker catalog (`/api/biomarkers`).
+ *
+ * Module gate: like `/api/labs/*`, this is intentionally NOT server-gated.
+ * The Labs module toggle is a UX-only nav preference; the data is owner-scoped
+ * and read by cross-feature surfaces (Vorsorge reminder resolution, the
+ * doctor-report PDF) regardless of the toggle. Deliberate opt-out, in contrast
+ * to the born-gated `/api/illness/*` routes.
+ *
+ * The catalog is the Labs feature's primary object: a marker is defined ONCE
+ * (name, unit, reference bounds, optional context) and every later reading
+ * just picks it. GET lists the caller's markers; POST defines a new one.
+ * `userId` is always narrowed from the session — never a body field — and the
+ * write `data` object is built field-by-field (no mass assignment). The
+ * optional context note is AES-256-GCM encrypted into the `contextEncrypted`
+ * Bytes column before write. The `@@unique([userId, name])` index means there
+ * is never a second "LDL" definition for one user.
+ */
+
+// The encrypted context bytes are never echoed back; `hasContext` flags
+// presence. The list path decrypts FAIL-SOFT (`decryptContextSoft` → null on
+// a bad-key / malformed row) so one un-decryptable row never 500s the whole
+// catalog — the single-resource GET is the authoritative read for the
+// plaintext context. This bounds the fail-closed blast radius to one marker.
+function serialiseBiomarker(row: {
+ id: string;
+ name: string;
+ unit: string;
+ lowerBound: number | null;
+ upperBound: number | null;
+ panel: string | null;
+ contextEncrypted: Uint8Array | null;
+ createdAt: Date;
+ updatedAt: Date;
+}) {
+ return {
+ id: row.id,
+ name: row.name,
+ unit: row.unit,
+ lowerBound: row.lowerBound,
+ upperBound: row.upperBound,
+ panel: row.panel,
+ hasContext: row.contextEncrypted !== null,
+ context: decryptContextSoft(row.contextEncrypted),
+ createdAt: row.createdAt.toISOString(),
+ updatedAt: row.updatedAt.toISOString(),
+ };
+}
+
+export const GET = apiHandler(async () => {
+ const { user } = await requireAuth();
+
+ const rows = await prisma.biomarker.findMany({
+ where: { userId: user.id },
+ orderBy: [{ name: "asc" }],
+ });
+
+ annotate({
+ action: { name: "labs.biomarker.list" },
+ meta: { total: rows.length },
+ });
+
+ return apiSuccess({ biomarkers: rows.map(serialiseBiomarker) });
+});
+
+export const POST = apiHandler(withIdempotency<[NextRequest]>(postBiomarker));
+
+async function postBiomarker(request: NextRequest) {
+ const { user } = await requireAuth();
+
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 16 * 1024,
+ });
+ if (jsonError) return jsonError;
+
+ const parsed = createBiomarkerSchema.safeParse(body);
+ if (!parsed.success) {
+ annotate({
+ action: { name: "labs.biomarker.create.validation-failed" },
+ meta: { issue_count: parsed.error.issues.length },
+ });
+ const auditIssues = sanitiseZodIssues(parsed.error.issues, {
+ stripValuesFromMessage: true,
+ });
+ prisma.auditLog
+ .create({
+ data: {
+ userId: user.id,
+ action: "labs.biomarker.create.validation-failed",
+ details: JSON.stringify({ issues: auditIssues }),
+ },
+ })
+ .catch(() => {
+ /* swallow — the 422 response is the contract */
+ });
+ return returnAllZodIssues(parsed.error, 422);
+ }
+
+ const { name, unit, lowerBound, upperBound, context, panel } = parsed.data;
+
+ // Reject a duplicate name up front so the client sees a clean 409 rather
+ // than a Prisma unique-constraint 500. The `@@unique([userId, name])` index
+ // is the structural backstop.
+ const existing = await prisma.biomarker.findFirst({
+ where: { userId: user.id, name },
+ select: { id: true },
+ });
+ if (existing) {
+ return apiError("A biomarker with this name already exists", 409);
+ }
+
+ // Field-by-field assignment — never spread `parsed.data`.
+ const created = await prisma.biomarker.create({
+ data: {
+ userId: user.id,
+ name,
+ unit,
+ lowerBound: lowerBound ?? null,
+ upperBound: upperBound ?? null,
+ panel: panel ?? null,
+ contextEncrypted: context ? encryptContextToBytes(context) : null,
+ },
+ });
+
+ await auditLog("biomarker.create", {
+ userId: user.id,
+ ipAddress: getClientIp(request),
+ details: { biomarkerId: created.id },
+ });
+
+ annotate({
+ action: { name: "labs.biomarker.create" },
+ meta: { biomarkerId: created.id },
+ });
+
+ return apiSuccess(serialiseBiomarker(created), 201);
+}
diff --git a/src/app/api/coach/reminder-suggestions/route.ts b/src/app/api/coach/reminder-suggestions/route.ts
new file mode 100644
index 000000000..170130bf5
--- /dev/null
+++ b/src/app/api/coach/reminder-suggestions/route.ts
@@ -0,0 +1,229 @@
+/**
+ * POST /api/coach/reminder-suggestions — act on a Coach cadence suggestion.
+ *
+ * v1.18.1 (Workstream C). The Coach proposes an evidence-based measurement
+ * cadence (the `---SUGGEST-REMINDER---` sentinel → `suggestion` SSE frame →
+ * action card). This endpoint is what the card's buttons call:
+ *
+ * - `accept` → create a `MeasurementReminder` with `origin: COACH`,
+ * routed through the SAME engine + endpoint semantics as the
+ * Vorsorge surface. The server resolves the cadence from the
+ * closed catalog (metric + interval/rrule + course window) —
+ * the client sends only the cadence id, never a schedule.
+ * - `dismiss` → dismissal memory (never re-suggest this cadence).
+ * - `stop` → the explicit "you measure enough — stop" path.
+ *
+ * Triple-dedup completion: the CREATE path here refuses a second live
+ * COACH reminder for the same metric, so a re-tap or a stale card cannot
+ * double-create even though the SUGGEST gate already deduped at emit time.
+ */
+import { NextRequest } from "next/server";
+
+import { Prisma } from "@/generated/prisma/client";
+import { prisma } from "@/lib/db";
+import { auditLog } from "@/lib/auth/audit";
+import { checkRateLimit } from "@/lib/rate-limit";
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import {
+ apiError,
+ apiSuccess,
+ getClientIp,
+ returnAllZodIssues,
+ safeJson,
+} from "@/lib/api-response";
+import { annotate } from "@/lib/logging/context";
+import { requireModuleEnabled } from "@/lib/modules/gate";
+import {
+ parseCoachPrefs,
+ DEFAULT_REMINDER_SUGGESTION_PREFS,
+} from "@/lib/validations/coach-prefs";
+import { coachReminderSuggestionActionSchema } from "@/lib/validations/coach-reminder-suggestion";
+import {
+ CADENCE_CATALOG,
+ isCadenceId,
+} from "@/lib/ai/coach/suggest-reminder";
+import {
+ computeReminderNextDueAt,
+ type ReminderScheduleInput,
+} from "@/lib/measurement-reminders/scheduling";
+import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
+
+const DEFAULT_TIMEZONE = "Europe/Berlin";
+
+async function postAction(request: NextRequest): Promise {
+ const { user } = await requireAuth();
+ // The Coach surface gates the suggestion card; mirror the gate here so a
+ // disabled-coach account cannot drive the endpoint directly.
+ const gate = await requireModuleEnabled(user.id, "coach");
+ if (!gate.enabled) return gate.response;
+
+ // Per-user rate limit — the action card is one-tap, so a tight per-user
+ // bucket (userId, never IP) is the right granularity. Mirrors the
+ // labs/restore convention; a trust-violation can never widen it.
+ const rate = await checkRateLimit(
+ `coach:reminder-suggestion:${user.id}`,
+ 30,
+ 60_000,
+ );
+ if (!rate.allowed) {
+ return apiError("coach.suggestion.rateLimited", 429);
+ }
+
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 4 * 1024,
+ });
+ if (jsonError) return jsonError;
+
+ const parsed = coachReminderSuggestionActionSchema.safeParse(body);
+ if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
+ const { cadenceId, action } = parsed.data;
+
+ if (!isCadenceId(cadenceId)) {
+ return apiError("coach.suggestion.unknownCadence", 422);
+ }
+ const cadence = CADENCE_CATALOG[cadenceId];
+
+ const row = await prisma.user.findUnique({
+ where: { id: user.id },
+ select: { coachPrefsJson: true, timezone: true },
+ });
+ const coachPrefs = parseCoachPrefs(row?.coachPrefsJson);
+ const suggestionPrefs =
+ coachPrefs.reminderSuggestions ?? DEFAULT_REMINDER_SUGGESTION_PREFS;
+
+ // ── dismiss / stop: prefs-only writes ────────────────────────
+ if (action === "dismiss" || action === "stop") {
+ const next = {
+ ...suggestionPrefs,
+ ...(action === "stop" ? { stopped: true } : {}),
+ dismissedCadences:
+ action === "dismiss" &&
+ !suggestionPrefs.dismissedCadences.includes(cadenceId)
+ ? [...suggestionPrefs.dismissedCadences, cadenceId].slice(0, 32)
+ : suggestionPrefs.dismissedCadences,
+ };
+ await prisma.user.update({
+ where: { id: user.id },
+ data: {
+ coachPrefsJson: { ...coachPrefs, reminderSuggestions: next },
+ },
+ });
+ annotate({
+ action: { name: `coach.reminder.${action}` },
+ meta: { cadenceId },
+ });
+ return apiSuccess({ ok: true, action });
+ }
+
+ // ── accept: create the reminder (origin: COACH) ──────────────
+ // Module-toggle re-check at create time (defence in depth — the gate
+ // already checked at emit, but a stale card could outlive a module flip).
+ if (cadence.module) {
+ const moduleGate = await requireModuleEnabled(user.id, cadence.module);
+ if (!moduleGate.enabled) return moduleGate.response;
+ }
+
+ // Third dedup: refuse a second live COACH reminder for the same metric.
+ const existing = await prisma.measurementReminder.findFirst({
+ where: {
+ userId: user.id,
+ origin: "COACH",
+ measurementType: cadence.measurementType,
+ deletedAt: null,
+ },
+ select: { id: true },
+ });
+ if (existing) {
+ annotate({
+ action: { name: "coach.reminder.accept.duplicate" },
+ meta: { cadenceId, reminderId: existing.id },
+ });
+ return apiSuccess(
+ { ok: true, action: "accept", reminder: null, duplicate: true },
+ 200,
+ );
+ }
+
+ const timezone = row?.timezone || DEFAULT_TIMEZONE;
+ const now = new Date();
+ const endsOn =
+ cadence.courseDays != null
+ ? new Date(now.getTime() + cadence.courseDays * 24 * 60 * 60 * 1000)
+ : null;
+
+ const scheduleInput: ReminderScheduleInput = {
+ intervalDays: cadence.intervalDays,
+ rrule: cadence.rrule,
+ anchorDate: null,
+ notifyHour: cadence.notifyHour,
+ lastSatisfiedAt: null,
+ createdAt: now,
+ endsOn,
+ };
+ const nextDueAt = computeReminderNextDueAt(scheduleInput, timezone, now);
+
+ // Field-by-field — no mass assignment. `origin: COACH` is the
+ // provenance the UI labels and the SUGGEST gate dedupes against.
+ let created;
+ try {
+ created = await prisma.measurementReminder.create({
+ data: {
+ userId: user.id,
+ label: cadence.labelKey,
+ measurementType: cadence.measurementType,
+ intervalDays: cadence.intervalDays,
+ rrule: cadence.rrule,
+ anchorDate: null,
+ endsOn,
+ origin: "COACH",
+ notifyHour: cadence.notifyHour,
+ location: null,
+ enabled: true,
+ nextDueAt,
+ },
+ });
+ } catch (err) {
+ // Structural dedup backstop: the 0172 partial unique index
+ // (`origin = 'COACH' AND deleted_at IS NULL` per user+metric) wins the
+ // race the read-then-create above can lose. Treat the violation as the
+ // same idempotent "already have one" outcome.
+ if (
+ err instanceof Prisma.PrismaClientKnownRequestError &&
+ err.code === "P2002"
+ ) {
+ annotate({
+ action: { name: "coach.reminder.accept.duplicate" },
+ meta: { cadenceId, reason: "unique-violation" },
+ });
+ return apiSuccess(
+ { ok: true, action: "accept", reminder: null, duplicate: true },
+ 200,
+ );
+ }
+ throw err;
+ }
+
+ await auditLog("measurementReminder.create.coach", {
+ userId: user.id,
+ ipAddress: getClientIp(request),
+ details: { reminderId: created.id, cadenceId },
+ });
+ annotate({
+ action: { name: "coach.reminder.accept" },
+ meta: { cadenceId, reminderId: created.id },
+ });
+
+ return apiSuccess(
+ {
+ ok: true,
+ action: "accept",
+ reminder: toMeasurementReminderDto(created),
+ duplicate: false,
+ },
+ 201,
+ );
+}
+
+export const POST = apiHandler(postAction);
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
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 661a4fa47..67a9ba6eb 100644
--- a/src/app/api/export/health-record/__tests__/route.test.ts
+++ b/src/app/api/export/health-record/__tests__/route.test.ts
@@ -18,6 +18,7 @@ vi.mock("@/lib/db", () => ({
medicationIntakeEvent: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
labResult: { findMany: vi.fn() },
+ illnessEpisode: { findMany: vi.fn() },
user: { findUnique: vi.fn(), update: vi.fn() },
},
}));
@@ -98,6 +99,7 @@ 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);
// First findUnique = aggregator profile select; second = route KVNR select.
vi.mocked(prisma.user.findUnique).mockResolvedValue({
username: "sample",
diff --git a/src/app/api/gamification/achievements/__tests__/hidden-redaction.test.ts b/src/app/api/gamification/achievements/__tests__/hidden-redaction.test.ts
index 0b419743c..0504280dd 100644
--- a/src/app/api/gamification/achievements/__tests__/hidden-redaction.test.ts
+++ b/src/app/api/gamification/achievements/__tests__/hidden-redaction.test.ts
@@ -14,6 +14,7 @@ vi.mock("@/lib/db", () => ({
createMany: vi.fn(),
},
userHealthProfile: { findUnique: vi.fn() },
+ illnessEpisode: { findMany: vi.fn() },
},
}));
@@ -95,6 +96,7 @@ beforeEach(() => {
count: 0,
} as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
+ vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.userHealthProfile.findUnique).mockResolvedValue(
null as never,
);
diff --git a/src/app/api/gamification/achievements/__tests__/ios-format.test.ts b/src/app/api/gamification/achievements/__tests__/ios-format.test.ts
index 313813feb..3393acc51 100644
--- a/src/app/api/gamification/achievements/__tests__/ios-format.test.ts
+++ b/src/app/api/gamification/achievements/__tests__/ios-format.test.ts
@@ -14,6 +14,7 @@ vi.mock("@/lib/db", () => ({
createMany: vi.fn(),
},
userHealthProfile: { findUnique: vi.fn() },
+ illnessEpisode: { findMany: vi.fn() },
},
}));
@@ -81,6 +82,7 @@ beforeEach(() => {
count: 0,
} as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
+ vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.userHealthProfile.findUnique).mockResolvedValue(
null as never,
);
diff --git a/src/app/api/gamification/achievements/__tests__/module-gate.test.ts b/src/app/api/gamification/achievements/__tests__/module-gate.test.ts
index 16d5465d1..9d88c4648 100644
--- a/src/app/api/gamification/achievements/__tests__/module-gate.test.ts
+++ b/src/app/api/gamification/achievements/__tests__/module-gate.test.ts
@@ -27,6 +27,7 @@ vi.mock("@/lib/db", () => ({
createMany: vi.fn(),
},
userHealthProfile: { findUnique: vi.fn() },
+ illnessEpisode: { findMany: vi.fn() },
},
}));
@@ -87,6 +88,7 @@ beforeEach(() => {
count: 0,
} as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
+ vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.userHealthProfile.findUnique).mockResolvedValue(
null as never,
);
diff --git a/src/app/api/gamification/achievements/route.ts b/src/app/api/gamification/achievements/route.ts
index 2b1821386..8cbb938b5 100644
--- a/src/app/api/gamification/achievements/route.ts
+++ b/src/app/api/gamification/achievements/route.ts
@@ -1,35 +1,13 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { requireModuleEnabled, resolveModuleMap } from "@/lib/modules/gate";
-import type { ModuleKey } from "@/lib/modules/gate";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import {
- ACHIEVEMENT_DEFINITIONS,
- GAMIFICATION_ROLLOUT_AT,
- applyDiscoveryFilter,
- calculateLongestStreak,
- evaluateAchievementsWithCompletionDates,
- moduleForMetric,
- toBerlinDayKey,
- type AchievementMetrics,
- type AchievementProgress,
-} from "@/lib/gamification/achievements";
-import {
- buildExpansionMetricValues,
- getEarnabilityFlags,
-} from "@/lib/gamification/expansion-metrics";
-import {
- getMissFreeDayKeys,
- getWeeklyConsistency,
-} from "@/lib/gamification/care-metrics";
-import {
- classifyBMI,
- classifyBP,
- classifyPulse,
-} from "@/lib/analytics/classifications";
-import { classifyIntakeTiming } from "@/lib/analytics/compliance";
+ buildAchievementsResult,
+ type AchievementsResult,
+} from "@/lib/gamification/achievements-result";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import type { NextRequest } from "next/server";
@@ -56,429 +34,6 @@ interface IosAchievement {
export const dynamic = "force-dynamic";
-const DAY_MS = 24 * 60 * 60 * 1000;
-const ROLLING_WINDOW_DAYS = 30;
-
-// v1.16.1 — weekly measurement-consistency badge: 4 consecutive weeks
-// with at least 5 distinct active vitals days each. Mirrors the
-// `measurement-weeks-4` definition's target.
-const MEASUREMENT_CONSISTENCY_MIN_DAYS_PER_WEEK = 5;
-const MEASUREMENT_CONSISTENCY_TARGET_WEEKS = 4;
-
-type IntakeEventRecord = {
- medicationId: string;
- scheduledFor: Date;
- takenAt: Date | null;
- skipped: boolean;
- /** v1.16.1 — feeds the miss-free day streak. */
- autoMissed: boolean;
-};
-
-type MedicationScheduleRecord = {
- windowStart: string;
- windowEnd: string;
- daysOfWeek: string | null;
-};
-
-type MeasurementRecord = {
- type: "WEIGHT" | "BLOOD_PRESSURE_SYS" | "BLOOD_PRESSURE_DIA" | "PULSE";
- value: number;
- measuredAt: Date;
-};
-
-function maxDate(a: Date, b: Date): Date {
- return a > b ? a : b;
-}
-
-function dayKeyToSerial(dayKey: string): number {
- const [year, month, day] = dayKey.split("-").map(Number);
- return Date.UTC(year, month - 1, day) / DAY_MS;
-}
-
-function serialToDayKey(serial: number): string {
- const date = new Date(serial * DAY_MS);
- const year = date.getUTCFullYear();
- const month = String(date.getUTCMonth() + 1).padStart(2, "0");
- const day = String(date.getUTCDate()).padStart(2, "0");
- return `${year}-${month}-${day}`;
-}
-
-function dayKeyToDate(dayKey: string): Date {
- const [year, month, day] = dayKey.split("-").map(Number);
- return new Date(Date.UTC(year, month - 1, day, 12, 0, 0));
-}
-
-function mean(values: number[]): number {
- if (values.length === 0) return 0;
- return values.reduce((total, value) => total + value, 0) / values.length;
-}
-
-function findCountCompletionDate(dates: Date[], target: number): Date | null {
- if (dates.length < target) return null;
- const sorted = [...dates].sort((a, b) => a.getTime() - b.getTime());
- return sorted[target - 1] ?? null;
-}
-
-function findStreakCompletionDate(
- dayKeys: string[],
- target: number,
- firstDateByDay: Map,
-): Date | null {
- if (dayKeys.length === 0) return null;
-
- let streak = 1;
- if (target === 1) {
- return firstDateByDay.get(dayKeys[0]) ?? null;
- }
-
- for (let i = 1; i < dayKeys.length; i++) {
- const previous = dayKeyToSerial(dayKeys[i - 1]);
- const current = dayKeyToSerial(dayKeys[i]);
- streak = current - previous === 1 ? streak + 1 : 1;
-
- if (streak >= target) {
- return firstDateByDay.get(dayKeys[i]) ?? null;
- }
- }
-
- return null;
-}
-
-function toDaySeries(dayKeys: string[]): {
- dayKeys: string[];
- firstDateByDay: Map;
-} {
- const sortedKeys = [...new Set(dayKeys)].sort();
- const firstDateByDay = new Map(
- sortedKeys.map((key) => [key, dayKeyToDate(key)]),
- );
-
- return {
- dayKeys: sortedKeys,
- firstDateByDay,
- };
-}
-
-function getEventDaySeries(dates: Date[]): {
- dayKeys: string[];
- firstDateByDay: Map;
-} {
- const firstDateByDay = new Map();
-
- for (const date of dates) {
- const dayKey = toBerlinDayKey(date);
- const existing = firstDateByDay.get(dayKey);
-
- if (!existing || date < existing) {
- firstDateByDay.set(dayKey, date);
- }
- }
-
- return {
- dayKeys: Array.from(firstDateByDay.keys()).sort(),
- firstDateByDay,
- };
-}
-
-function getHealthGreenDaySeries(
- measurements: MeasurementRecord[],
- heightCm: number | null,
-) {
- const dayStats = new Map<
- string,
- {
- weight: number[];
- bpSys: number[];
- bpDia: number[];
- pulse: number[];
- }
- >();
-
- for (const measurement of measurements) {
- const dayKey = toBerlinDayKey(measurement.measuredAt);
- const bucket = dayStats.get(dayKey) ?? {
- weight: [],
- bpSys: [],
- bpDia: [],
- pulse: [],
- };
-
- if (measurement.type === "WEIGHT") bucket.weight.push(measurement.value);
- if (measurement.type === "BLOOD_PRESSURE_SYS")
- bucket.bpSys.push(measurement.value);
- if (measurement.type === "BLOOD_PRESSURE_DIA")
- bucket.bpDia.push(measurement.value);
- if (measurement.type === "PULSE") bucket.pulse.push(measurement.value);
-
- dayStats.set(dayKey, bucket);
- }
-
- const bmiGreenDays: string[] = [];
- const bpGreenDays: string[] = [];
- const pulseGreenDays: string[] = [];
-
- for (const [dayKey, bucket] of dayStats.entries()) {
- if (heightCm && bucket.weight.length > 0) {
- const bmi = mean(bucket.weight) / (heightCm / 100) ** 2;
- if (classifyBMI(bmi).severity === "normal") {
- bmiGreenDays.push(dayKey);
- }
- }
-
- if (bucket.bpSys.length > 0 && bucket.bpDia.length > 0) {
- const bpClass = classifyBP(mean(bucket.bpSys), mean(bucket.bpDia));
- if (bpClass.severity === "normal") {
- bpGreenDays.push(dayKey);
- }
- }
-
- if (bucket.pulse.length > 0) {
- const pulseClass = classifyPulse(mean(bucket.pulse));
- if (pulseClass.severity === "normal") {
- pulseGreenDays.push(dayKey);
- }
- }
- }
-
- return {
- bmi: toDaySeries(bmiGreenDays),
- bp: toDaySeries(bpGreenDays),
- pulse: toDaySeries(pulseGreenDays),
- };
-}
-
-function getOnTimePerfectDaySeries(
- intakeEvents: IntakeEventRecord[],
- schedulesByMedicationId: Map,
-) {
- const eventsByDay = new Map();
-
- for (const event of intakeEvents) {
- const dayKey = toBerlinDayKey(event.scheduledFor);
- const list = eventsByDay.get(dayKey) ?? [];
- list.push(event);
- eventsByDay.set(dayKey, list);
- }
-
- const perfectDays: string[] = [];
-
- for (const [dayKey, events] of eventsByDay.entries()) {
- if (events.length === 0) continue;
-
- const scheduledDate = dayKeyToDate(dayKey);
- let isPerfectDay = true;
-
- for (const event of events) {
- if (event.skipped || event.takenAt === null) {
- isPerfectDay = false;
- break;
- }
-
- const schedules = schedulesByMedicationId.get(event.medicationId) ?? [];
- if (schedules.length === 0) {
- continue;
- }
-
- const eventMinutes =
- event.scheduledFor.getUTCHours() * 60 +
- event.scheduledFor.getUTCMinutes();
-
- let bestSchedule = schedules[0];
- let bestDistance = Infinity;
-
- for (const schedule of schedules) {
- const [h, m] = schedule.windowStart.split(":").map(Number);
- const scheduleMinutes = h * 60 + m;
- const distance = Math.abs(eventMinutes - scheduleMinutes);
- if (distance < bestDistance) {
- bestDistance = distance;
- bestSchedule = schedule;
- }
- }
-
- const timing = classifyIntakeTiming(
- event.takenAt,
- bestSchedule.windowStart,
- bestSchedule.windowEnd,
- scheduledDate,
- );
-
- // v1.4.34 IW-C — `early` is also a compliant bucket (dose taken
- // within the 3h pre-window grace), so it preserves a perfect day.
- if (timing !== "on_time" && timing !== "early") {
- isPerfectDay = false;
- break;
- }
- }
-
- if (isPerfectDay) {
- perfectDays.push(dayKey);
- }
- }
-
- return toDaySeries(perfectDays);
-}
-
-function parseDaysOfWeek(daysOfWeek: string | null): Set | null {
- if (!daysOfWeek) return null;
-
- const values = daysOfWeek
- .split(",")
- .map((value) => Number(value.trim()))
- .filter((value) => Number.isInteger(value) && value >= 0 && value <= 6);
-
- return values.length > 0 ? new Set(values) : null;
-}
-
-function getExpectedIntakesForDay(
- schedules: MedicationScheduleRecord[],
- dayKey: string,
-): number {
- if (schedules.length === 0) return 0;
- if (!/^\d{4}-\d{2}-\d{2}$/.test(dayKey)) return 0;
-
- const day = dayKeyToDate(dayKey).getUTCDay();
- let expected = 0;
-
- for (const schedule of schedules) {
- const allowedDays = parseDaysOfWeek(schedule.daysOfWeek);
- if (!allowedDays || allowedDays.has(day)) {
- expected += 1;
- }
- }
-
- return expected;
-}
-
-function getIntakeIssueMetrics(
- intakeEvents: IntakeEventRecord[],
- schedulesByMedicationId: Map,
-) {
- const skippedIntakeDates = intakeEvents
- .filter((event) => event.skipped)
- .map((event) => event.scheduledFor)
- .sort((a, b) => a.getTime() - b.getTime());
-
- const eventsByMedicationDay = new Map<
- string,
- Map
- >();
-
- for (const event of intakeEvents) {
- if (!event.medicationId || Number.isNaN(event.scheduledFor.getTime())) {
- continue;
- }
-
- const dayKey = toBerlinDayKey(event.scheduledFor);
- const byDay = eventsByMedicationDay.get(event.medicationId) ?? new Map();
- const list = byDay.get(dayKey) ?? [];
- list.push(event);
- byDay.set(dayKey, list);
- eventsByMedicationDay.set(event.medicationId, byDay);
- }
-
- let overIntakeCount = 0;
- const overIntakeDates: Date[] = [];
-
- for (const [medicationId, dayMap] of eventsByMedicationDay.entries()) {
- const schedules = schedulesByMedicationId.get(medicationId) ?? [];
-
- for (const [dayKey, events] of dayMap.entries()) {
- const expectedCount = getExpectedIntakesForDay(schedules, dayKey);
-
- if (expectedCount <= 0) {
- continue;
- }
-
- const takenEvents = events
- .filter((event) => !event.skipped && event.takenAt !== null)
- .sort((a, b) => {
- const aTime = (a.takenAt ?? a.scheduledFor).getTime();
- const bTime = (b.takenAt ?? b.scheduledFor).getTime();
- return aTime - bTime;
- });
-
- const excessCount = Math.max(0, takenEvents.length - expectedCount);
- if (excessCount === 0) {
- continue;
- }
-
- overIntakeCount += excessCount;
-
- for (
- let index = takenEvents.length - excessCount;
- index < takenEvents.length;
- index++
- ) {
- const event = takenEvents[index];
- if (!event) continue;
- overIntakeDates.push(event.takenAt ?? event.scheduledFor);
- }
- }
- }
-
- overIntakeDates.sort((a, b) => a.getTime() - b.getTime());
-
- return {
- skippedIntakeCount: skippedIntakeDates.length,
- skippedIntakeDates,
- overIntakeCount,
- overIntakeDates,
- };
-}
-
-function getCompliance80DaySeries(
- intakeEvents: IntakeEventRecord[],
- startDate: Date,
- endDate: Date,
-) {
- const perDayCounts = new Map();
-
- for (const event of intakeEvents) {
- const serial = dayKeyToSerial(toBerlinDayKey(event.scheduledFor));
- const current = perDayCounts.get(serial) ?? { logged: 0, taken: 0 };
-
- const logged = event.skipped || event.takenAt !== null;
- const taken = !event.skipped && event.takenAt !== null;
-
- if (logged) current.logged += 1;
- if (taken) current.taken += 1;
-
- perDayCounts.set(serial, current);
- }
-
- const startSerial = dayKeyToSerial(toBerlinDayKey(startDate));
- const endSerial = dayKeyToSerial(toBerlinDayKey(endDate));
-
- let rollingLogged = 0;
- let rollingTaken = 0;
- const greenDays: string[] = [];
-
- for (let serial = startSerial; serial <= endSerial; serial++) {
- const todayCounts = perDayCounts.get(serial);
- if (todayCounts) {
- rollingLogged += todayCounts.logged;
- rollingTaken += todayCounts.taken;
- }
-
- const outOfWindowCounts = perDayCounts.get(serial - ROLLING_WINDOW_DAYS);
- if (outOfWindowCounts) {
- rollingLogged -= outOfWindowCounts.logged;
- rollingTaken -= outOfWindowCounts.taken;
- }
-
- const hasFullWindow = serial >= startSerial + (ROLLING_WINDOW_DAYS - 1);
- if (!hasFullWindow || rollingLogged <= 0) continue;
-
- const compliance = Math.round((rollingTaken / rollingLogged) * 100);
- if (compliance >= 80) {
- greenDays.push(serialToDayKey(serial));
- }
- }
-
- return toDaySeries(greenDays);
-}
-
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
@@ -577,618 +132,3 @@ export const GET = apiHandler(async (request: NextRequest) => {
return apiSuccess(payload);
});
-
-type AuthedUser = Awaited>["user"];
-
-type AchievementsResult = Awaited>;
-
-/**
- * v1.4.34 IW-G — pulled out of the GET handler so `cached()` can wrap
- * the heavy aggregation. Returns the locale-agnostic web payload; the
- * iOS-format transform runs in the handler after the cache read.
- *
- * v1.18.0 B5 — this builder NO LONGER writes. It computes the not-yet-
- * persisted unlocks and returns them as `pendingUnlocks`; the handler
- * performs the idempotent `createMany({ skipDuplicates: true })` on every
- * request after the cache read. That keeps the persistence off the cached
- * path (a cache hit can't silently skip it, a concurrent miss can't
- * duplicate it) while the unique `(userId, achievementId)` makes a repeat
- * write a no-op.
- */
-async function buildAchievementsResult(
- user: AuthedUser,
- moduleMap: Record,
-) {
- const now = new Date();
- const userId = user.id;
- const startDate = maxDate(user.createdAt, GAMIFICATION_ROLLOUT_AT);
-
- const [
- measurements,
- intakeEvents,
- medications,
- passkeys,
- auditEvents,
- moodEntries,
- sleepMeasurements,
- healthProfile,
- ] = await Promise.all([
- prisma.measurement.findMany({
- where: {
- userId,
- measuredAt: { gte: startDate, lte: now },
- source: { not: "IMPORT" },
- type: {
- in: ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "PULSE"],
- },
- // v1.4.41 W-DELETED-2 — soft-deleted measurements are excluded
- // from achievement progress so deleting a row immediately rolls
- // back any streak / count badge it earned.
- deletedAt: null,
- },
- select: {
- type: true,
- value: true,
- measuredAt: true,
- },
- }),
- prisma.medicationIntakeEvent.findMany({
- where: {
- userId,
- // v1.7.0 sync — exclude tombstoned rows from streak counting.
- deletedAt: null,
- source: { not: "IMPORT" },
- scheduledFor: { gte: startDate, lte: now },
- },
- select: {
- medicationId: true,
- scheduledFor: true,
- takenAt: true,
- skipped: true,
- // v1.16.1 — the miss-free streak disqualifies a day on any
- // auto-missed slot.
- autoMissed: true,
- },
- }),
- prisma.medication.findMany({
- where: { userId },
- select: {
- id: true,
- schedules: {
- select: {
- windowStart: true,
- windowEnd: true,
- daysOfWeek: true,
- },
- },
- },
- }),
- prisma.passkey.findMany({
- where: {
- userId,
- createdAt: { gte: startDate, lte: now },
- },
- select: {
- createdAt: true,
- },
- }),
- prisma.auditLog.findMany({
- where: {
- userId,
- createdAt: { gte: startDate, lte: now },
- action: {
- in: [
- "auth.login.passkey",
- "auth.login.password",
- "bugreport.submit",
- // v1.4.18 — hidden Easter-egg triggers
- "doctor-report.generate",
- "doctor-report.pdf.generate",
- "settings.locale.update",
- ],
- },
- },
- select: {
- action: true,
- createdAt: true,
- },
- }),
- // v1.4.18 — mood entries feed the new mood badges + the
- // entry-streak/consistent-month engagement metrics. Synced from
- // moodLog.app or entered directly; we intentionally include all
- // sources because consistency-of-tracking is what the badge
- // rewards.
- prisma.moodEntry.findMany({
- where: {
- userId,
- // v1.7.0 sync — exclude tombstoned rows from streak counting.
- deletedAt: null,
- moodLoggedAt: { gte: startDate, lte: now },
- },
- select: {
- date: true,
- score: true,
- moodLoggedAt: true,
- },
- }),
- // v1.16.1 — sleep samples feed the sleep-logging streak. Read
- // separately from the vitals query above so passive sleep syncs
- // never inflate the entry-day / consistent-month engagement
- // metrics, which reward ACTIVE tracking.
- prisma.measurement.findMany({
- where: {
- userId,
- measuredAt: { gte: startDate, lte: now },
- source: { not: "IMPORT" },
- type: "SLEEP_DURATION",
- deletedAt: null,
- },
- select: { measuredAt: true },
- }),
- // v1.16.1 — presence-only read of the self-context questionnaire
- // (nothing is decrypted) for the maintained-self-report badge.
- prisma.userHealthProfile.findUnique({
- where: { userId },
- select: {
- aboutMeEncrypted: true,
- conditionsEncrypted: true,
- allergiesEncrypted: true,
- coachFocusEncrypted: true,
- updatedAt: true,
- },
- }),
- ]);
-
- const schedulesByMedicationId = new Map(
- medications.map((med) => [med.id, med.schedules]),
- );
- const intakeIssueMetrics = getIntakeIssueMetrics(
- intakeEvents,
- schedulesByMedicationId,
- );
-
- const takenIntakeDates = intakeEvents
- .filter((event) => !event.skipped && event.takenAt !== null)
- .map((event) => event.takenAt as Date);
- const passkeyCreatedDates = passkeys.map((passkey) => passkey.createdAt);
- const passkeyLoginDates = auditEvents
- .filter((event) => event.action === "auth.login.passkey")
- .map((event) => event.createdAt);
- const passwordLoginDates = auditEvents
- .filter((event) => event.action === "auth.login.password")
- .map((event) => event.createdAt);
- const bugReportDates = auditEvents
- .filter((event) => event.action === "bugreport.submit")
- .map((event) => event.createdAt);
- const loginDaySeries = getEventDaySeries([
- ...passkeyLoginDates,
- ...passwordLoginDates,
- ]);
-
- const healthSeries = getHealthGreenDaySeries(
- measurements as MeasurementRecord[],
- user.heightCm,
- );
- const onTimeSeries = getOnTimePerfectDaySeries(
- intakeEvents,
- schedulesByMedicationId,
- );
- const complianceSeries = getCompliance80DaySeries(
- intakeEvents,
- startDate,
- now,
- );
-
- // v1.16.1 — care-routine series. Miss-free days and sleep-logging
- // days reuse the standard day-series plumbing; the weekly measurement
- // consistency folds distinct active vitals days into Monday-anchored
- // weeks (≥5 active days each, 4 consecutive weeks for the badge).
- const missFreeSeries = toDaySeries(getMissFreeDayKeys(intakeEvents));
- const sleepSeries = getEventDaySeries(
- sleepMeasurements.map((m) => m.measuredAt),
- );
- const weeklyConsistency = getWeeklyConsistency(
- measurements.map((m) => toBerlinDayKey(m.measuredAt)),
- MEASUREMENT_CONSISTENCY_MIN_DAYS_PER_WEEK,
- MEASUREMENT_CONSISTENCY_TARGET_WEEKS,
- );
- const selfContextComplete =
- healthProfile !== null &&
- healthProfile.aboutMeEncrypted !== null &&
- healthProfile.conditionsEncrypted !== null &&
- healthProfile.allergiesEncrypted !== null &&
- healthProfile.coachFocusEncrypted !== null
- ? 1
- : 0;
-
- const expansionValues = buildExpansionMetricValues({
- measurements,
- moodEntries,
- intakeEvents,
- auditEvents,
- });
- const earnability = getEarnabilityFlags({
- hasMedication: medications.length > 0,
- moodEntryCount: expansionValues.moodEntryCount,
- measurementCounts: {
- weightCount: expansionValues.weightMeasurementCount,
- bpCount: expansionValues.bpMeasurementCount,
- pulseCount: expansionValues.pulseMeasurementCount,
- },
- sleepSampleCount: sleepMeasurements.length,
- });
-
- const metrics = {
- totalTakenIntakes: takenIntakeDates.length,
- overIntakeCount: intakeIssueMetrics.overIntakeCount,
- skippedIntakeCount: intakeIssueMetrics.skippedIntakeCount,
- bmiGreenStreak: calculateLongestStreak(healthSeries.bmi.dayKeys),
- bpGreenStreak: calculateLongestStreak(healthSeries.bp.dayKeys),
- pulseGreenStreak: calculateLongestStreak(healthSeries.pulse.dayKeys),
- onTimePerfectDayStreak: calculateLongestStreak(onTimeSeries.dayKeys),
- compliance80DayStreak: calculateLongestStreak(complianceSeries.dayKeys),
- passkeyCreatedCount: passkeyCreatedDates.length,
- passkeyLoginCount: passkeyLoginDates.length,
- passwordLoginCount: passwordLoginDates.length,
- loginDayStreak: calculateLongestStreak(loginDaySeries.dayKeys),
- bugReportCount: bugReportDates.length,
- ...expansionValues,
- // v1.16.1 — care-routine metrics.
- missFreeDayStreak: calculateLongestStreak(missFreeSeries.dayKeys),
- measurementConsistencyWeeks: weeklyConsistency.longestRunWeeks,
- selfContextCompleteCount: selfContextComplete,
- sleepLogDayStreak: calculateLongestStreak(sleepSeries.dayKeys),
- };
-
- const completionDates: Partial> = {};
-
- for (const definition of ACHIEVEMENT_DEFINITIONS) {
- if (definition.metric === "totalTakenIntakes") {
- const completedAt = findCountCompletionDate(
- takenIntakeDates,
- definition.target,
- );
- if (completedAt) completionDates[definition.id] = completedAt;
- continue;
- }
-
- if (definition.metric === "passkeyCreatedCount") {
- const completedAt = findCountCompletionDate(
- passkeyCreatedDates,
- definition.target,
- );
- if (completedAt) completionDates[definition.id] = completedAt;
- continue;
- }
-
- if (definition.metric === "passkeyLoginCount") {
- const completedAt = findCountCompletionDate(
- passkeyLoginDates,
- definition.target,
- );
- if (completedAt) completionDates[definition.id] = completedAt;
- continue;
- }
-
- if (definition.metric === "passwordLoginCount") {
- const completedAt = findCountCompletionDate(
- passwordLoginDates,
- definition.target,
- );
- if (completedAt) completionDates[definition.id] = completedAt;
- continue;
- }
-
- if (definition.metric === "bugReportCount") {
- const completedAt = findCountCompletionDate(
- bugReportDates,
- definition.target,
- );
- if (completedAt) completionDates[definition.id] = completedAt;
- continue;
- }
-
- if (definition.metric === "overIntakeCount") {
- const completedAt = findCountCompletionDate(
- intakeIssueMetrics.overIntakeDates,
- definition.target,
- );
- if (completedAt) completionDates[definition.id] = completedAt;
- continue;
- }
-
- if (definition.metric === "skippedIntakeCount") {
- const completedAt = findCountCompletionDate(
- intakeIssueMetrics.skippedIntakeDates,
- definition.target,
- );
- if (completedAt) completionDates[definition.id] = completedAt;
- continue;
- }
-
- if (definition.metric === "loginDayStreak") {
- const completedAt = findStreakCompletionDate(
- loginDaySeries.dayKeys,
- definition.target,
- loginDaySeries.firstDateByDay,
- );
- if (completedAt) completionDates[definition.id] = completedAt;
- continue;
- }
-
- if (definition.metric === "bmiGreenStreak") {
- const completedAt = findStreakCompletionDate(
- healthSeries.bmi.dayKeys,
- definition.target,
- healthSeries.bmi.firstDateByDay,
- );
- if (completedAt) completionDates[definition.id] = completedAt;
- continue;
- }
-
- if (definition.metric === "bpGreenStreak") {
- const completedAt = findStreakCompletionDate(
- healthSeries.bp.dayKeys,
- definition.target,
- healthSeries.bp.firstDateByDay,
- );
- if (completedAt) completionDates[definition.id] = completedAt;
- continue;
- }
-
- if (definition.metric === "pulseGreenStreak") {
- const completedAt = findStreakCompletionDate(
- healthSeries.pulse.dayKeys,
- definition.target,
- healthSeries.pulse.firstDateByDay,
- );
- if (completedAt) completionDates[definition.id] = completedAt;
- continue;
- }
-
- if (definition.metric === "onTimePerfectDayStreak") {
- const completedAt = findStreakCompletionDate(
- onTimeSeries.dayKeys,
- definition.target,
- onTimeSeries.firstDateByDay,
- );
- if (completedAt) completionDates[definition.id] = completedAt;
- continue;
- }
-
- if (definition.metric === "compliance80DayStreak") {
- const completedAt = findStreakCompletionDate(
- complianceSeries.dayKeys,
- definition.target,
- complianceSeries.firstDateByDay,
- );
- if (completedAt) completionDates[definition.id] = completedAt;
- continue;
- }
-
- // ── v1.16.1 ─ care-routine completion dates ─────────────────────
- if (definition.metric === "missFreeDayStreak") {
- const completedAt = findStreakCompletionDate(
- missFreeSeries.dayKeys,
- definition.target,
- missFreeSeries.firstDateByDay,
- );
- if (completedAt) completionDates[definition.id] = completedAt;
- continue;
- }
-
- if (definition.metric === "sleepLogDayStreak") {
- const completedAt = findStreakCompletionDate(
- sleepSeries.dayKeys,
- definition.target,
- sleepSeries.firstDateByDay,
- );
- if (completedAt) completionDates[definition.id] = completedAt;
- continue;
- }
-
- if (definition.metric === "measurementConsistencyWeeks") {
- if (weeklyConsistency.completionDayKey) {
- completionDates[definition.id] = dayKeyToDate(
- weeklyConsistency.completionDayKey,
- );
- }
- continue;
- }
-
- if (definition.metric === "selfContextCompleteCount") {
- if (selfContextComplete === 1 && healthProfile) {
- completionDates[definition.id] = healthProfile.updatedAt;
- }
- }
- }
-
- // Load persisted achievements
- const persisted = await prisma.userAchievement.findMany({
- where: { userId },
- select: { achievementId: true, unlockedAt: true },
- });
- const persistedMap = new Map(
- persisted.map((p) => [p.achievementId, p.unlockedAt]),
- );
-
- // Merge: persisted dates take priority, then calculated dates
- for (const [id, date] of Object.entries(completionDates)) {
- if (!persistedMap.has(id) && date) {
- persistedMap.set(id, date);
- }
- }
-
- // Use persisted dates as the source of truth
- const mergedDates: Partial> = {};
- for (const [id, date] of persistedMap) {
- mergedDates[id] = date;
- }
-
- const fullResult = evaluateAchievementsWithCompletionDates(
- metrics,
- mergedDates,
- );
-
- // v1.18.0 B5 — drop badges whose owning module is disabled BEFORE both
- // unlock-persistence and serialisation. A sleep badge must never unlock
- // (or render) while the sleep module is off; same for mood badges when
- // mood is off, etc. Core-domain and account-wide badges return a null
- // owner from `moduleForMetric` and always pass through.
- const moduleEnabledAchievements = fullResult.achievements.filter((a) => {
- const owner = moduleForMetric(a.metric);
- if (owner === null) return true;
- return moduleMap[owner as ModuleKey] !== false;
- });
-
- // v1.18.0 B5 — compute the newly unlocked rows here but DON'T write them
- // inside the cached factory: a write must not be a side effect of a
- // cached GET (a cache hit would silently skip it, a concurrent miss
- // could duplicate it). The handler performs the idempotent
- // `createMany({ skipDuplicates: true })` on every request after the
- // cache read, so the persistence runs regardless of cache outcome and
- // is safe to repeat.
- const pendingUnlocks: PendingUnlock[] = moduleEnabledAchievements
- .filter(
- (a) =>
- a.unlocked &&
- a.completedAt &&
- !persisted.some((p) => p.achievementId === a.id),
- )
- .map((a) => ({ achievementId: a.id, unlockedAt: a.completedAt! }));
-
- // v1.4.18 — apply the discovery filter so locked badges that the
- // user has *no path* to earn (e.g. mood badges for someone who has
- // never logged a mood entry) don't clutter the page. Hidden Easter-
- // eggs always pass through. Already-unlocked badges always pass
- // through (regression guard).
- const visibleAchievements = applyDiscoveryFilter(
- moduleEnabledAchievements,
- earnability,
- );
-
- // Recompute the summary so the headline counters reflect the
- // discovered set. Hidden achievements that are still locked are
- // counted toward `totalCount` so the user sees they exist.
- const visibleUnlocked = visibleAchievements.filter((a) => a.unlocked);
- const visibleEarned = visibleUnlocked.reduce((acc, a) => acc + a.points, 0);
- const visibleTotalPoints = visibleAchievements.reduce(
- (acc, a) => acc + a.points,
- 0,
- );
- const nextAchievement =
- visibleAchievements
- .filter((a) => !a.unlocked && !a.isHidden)
- .sort((a, b) => b.progressPercent - a.progressPercent)[0] ?? null;
-
- // v1.4.18 reconcile — redact hidden+locked entries before serialising
- // so a curious user opening DevTools/Network can't read the trigger
- // semantics from `metric`, `titleKey`, `descriptionKey`, `icon`,
- // `target`, `current`, or `progressPercent`. The DOM render path
- // already short-circuits on `isHidden && !unlocked`; this closes the
- // wire-shape leak. Once an Easter-egg unlocks, the real fields ship
- // (the unlock IS the moment to reveal the surprise).
- const redactedAchievements = visibleAchievements.map((a) =>
- redactIfHiddenLocked(a),
- );
- const redactedNextAchievement = nextAchievement
- ? redactIfHiddenLocked(nextAchievement)
- : null;
- const redactedMetrics = redactHiddenMetrics(
- fullResult.metrics,
- visibleAchievements,
- );
-
- const result = {
- summary: {
- unlockedCount: visibleUnlocked.length,
- totalCount: visibleAchievements.length,
- earnedPoints: visibleEarned,
- totalPoints: visibleTotalPoints,
- completionPercent:
- visibleAchievements.length === 0
- ? 100
- : Math.round(
- (visibleUnlocked.length / visibleAchievements.length) * 100,
- ),
- nextAchievement: redactedNextAchievement,
- },
- achievements: redactedAchievements,
- metrics: redactedMetrics,
- // v1.18.0 B5 — carried out of the cached factory so the handler can
- // persist them on every read (idempotent), not as a write side effect
- // that a cache hit would skip. NOT part of the serialised payload.
- pendingUnlocks,
- };
-
- return result;
-}
-
-/** v1.18.0 B5 — a not-yet-persisted unlock the handler writes after the cache read. */
-interface PendingUnlock {
- achievementId: string;
- unlockedAt: string;
-}
-
-/**
- * Hidden Easter-eggs ship to the client *only* as opaque cards while
- * locked. Returning the full `AchievementProgress` shape would let a
- * snooper read the trigger semantics from `metric`, `titleKey`, etc.
- * This helper projects locked+hidden entries down to the safe shape
- * the UI's opaque-placeholder branch needs.
- *
- * Unlocked hidden achievements pass through unchanged — the unlock IS
- * the reveal moment, so the toast and the unlocked card show the real
- * strings.
- */
-function redactIfHiddenLocked(
- achievement: AchievementProgress,
-): AchievementProgress {
- if (!achievement.isHidden || achievement.unlocked) return achievement;
- return {
- id: achievement.id,
- metric: "totalTakenIntakes",
- category: "hidden",
- titleKey: "achievements.hiddenCard.title",
- descriptionKey: "achievements.hiddenCard.description",
- icon: "HelpCircle",
- format: "count",
- target: 0,
- current: 0,
- points: achievement.points,
- unlocked: false,
- progressPercent: 0,
- completedAt: null,
- isHidden: true,
- };
-}
-
-/**
- * Drop hidden-only metric counters from the response `metrics` block
- * unless any hidden achievement they back is unlocked. The DOM never
- * reads these counters today — the only consumer is the iOS client's
- * progress bar — so removing the hidden ones from the wire is a pure
- * spoiler shield with zero UI impact.
- */
-function redactHiddenMetrics(
- metrics: AchievementMetrics,
- achievements: AchievementProgress[],
-): Partial {
- const HIDDEN_METRIC_KEYS = [
- "nightOwlCount",
- "earlyBirdCount",
- "leapDayCount",
- "doctorPdfCount",
- "localeFlipCount",
- ] as const;
- const safe: Partial = { ...metrics };
- for (const key of HIDDEN_METRIC_KEYS) {
- const backing = achievements.find(
- (a) => a.isHidden && a.metric === key && a.unlocked,
- );
- if (!backing) {
- delete safe[key];
- }
- }
- return safe;
-}
diff --git a/src/app/api/illness/episodes/[id]/correlation/route.ts b/src/app/api/illness/episodes/[id]/correlation/route.ts
new file mode 100644
index 000000000..a315f69ae
--- /dev/null
+++ b/src/app/api/illness/episodes/[id]/correlation/route.ts
@@ -0,0 +1,87 @@
+/**
+ * `GET /api/illness/episodes/{id}/correlation` — the retrospective correlation
+ * findings for one episode: the pre-onset anomaly scan ("how did it announce
+ * itself"), the nadir / what-dropped, and the recovery-gap (physiological
+ * return-to-baseline vs the felt-better marker).
+ *
+ * Server-authoritative + coverage-gated: the response is a `Derived` flat
+ * wire shape (`status: "ok" | "insufficient"`). When the signal is thin the
+ * engine returns `insufficient` with coverage + a reason — the surface renders
+ * "we're still learning", never a fabricated number. The findings are derived
+ * from the user's OWN baseline (median ± MAD), never a population constant, and
+ * the baseline window is contamination-guarded (ends before the pre-onset
+ * lookback). Retrospective ONLY — never a predictor or diagnoser. Born-gated +
+ * owner-scoped.
+ */
+import { NextRequest } from "next/server";
+
+import { prisma } from "@/lib/db";
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import { annotate } from "@/lib/logging/context";
+import { apiSuccess, apiError } from "@/lib/api-response";
+import { requireIllnessEnabled } from "@/lib/illness/gate";
+import { computeEpisodeCorrelation } from "@/lib/illness/correlation-read";
+import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
+
+type RouteParams = { params: Promise<{ id: string }> };
+
+export const GET = apiHandler(
+ async (_request: NextRequest, { params }: RouteParams) => {
+ const { user } = await requireAuth();
+
+ const gate = await requireIllnessEnabled(user.id);
+ if (!gate.enabled) return gate.response;
+
+ const { id } = await params;
+ const episode = await prisma.illnessEpisode.findUnique({
+ where: { id },
+ select: {
+ id: true,
+ userId: true,
+ deletedAt: true,
+ onsetAt: true,
+ resolvedAt: true,
+ lifecycle: true,
+ },
+ });
+ if (!episode || episode.userId !== user.id || episode.deletedAt !== null) {
+ return apiError("Episode not found", 404);
+ }
+
+ const tz = user.timezone ?? DEFAULT_TIMEZONE;
+ const derived = await computeEpisodeCorrelation(
+ user.id,
+ {
+ id: episode.id,
+ onsetAt: episode.onsetAt,
+ resolvedAt: episode.resolvedAt,
+ lifecycle: episode.lifecycle,
+ },
+ tz,
+ );
+
+ annotate({
+ action: {
+ name: "illness.correlation.read",
+ entity_type: "illness_episode",
+ entity_id: id,
+ },
+ meta: {
+ status: derived.status,
+ recovery_gap_days:
+ derived.status === "ok" ? derived.value.recoveryGapDays : null,
+ red_flags: derived.status === "ok" ? derived.value.redFlags.length : 0,
+ },
+ });
+
+ return apiSuccess({
+ episodeId: id,
+ status: derived.status,
+ value: derived.status === "ok" ? derived.value : null,
+ coverage: derived.coverage,
+ confidence: derived.status === "ok" ? derived.confidence : null,
+ provenance: derived.provenance,
+ reason: derived.status === "insufficient" ? derived.reason : null,
+ });
+ },
+);
diff --git a/src/app/api/illness/episodes/[id]/day-logs/route.ts b/src/app/api/illness/episodes/[id]/day-logs/route.ts
new file mode 100644
index 000000000..3d5b70de8
--- /dev/null
+++ b/src/app/api/illness/episodes/[id]/day-logs/route.ts
@@ -0,0 +1,135 @@
+/**
+ * `GET /api/illness/episodes/{id}/day-logs?date=YYYY-MM-DD` — the one
+ * day-log for the episode + day, or `null` when nothing is logged
+ * (lets the log-day sheet pre-fill).
+ * `POST /api/illness/episodes/{id}/day-logs` — upsert the day's symptom /
+ * functional-impact / fever timeline row. Upserts on
+ * `(episodeId, date)`: 201 on insert, 200 on update.
+ *
+ * Born-gated + owner-scoped (the episode must be owned + live). `note` is
+ * encrypted at rest; `userId` is narrowed from auth, never a body field.
+ */
+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 { requireIllnessEnabled } from "@/lib/illness/gate";
+import {
+ illnessDayLogInputSchema,
+ illnessDayLogQuerySchema,
+} from "@/lib/validations/illness";
+import { upsertIllnessDayLog } from "@/lib/illness/day-log-write";
+import { toIllnessDayLogDTO, dayLogSymptomInclude } from "@/lib/illness/dto";
+import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
+
+type RouteParams = { params: Promise<{ id: string }> };
+
+/** Resolve the episode, 404ing unless it is owned + live. */
+async function loadOwnedEpisode(
+ episodeId: string,
+ userId: string,
+): Promise<{ ok: true } | { ok: false; response: Response }> {
+ const episode = await prisma.illnessEpisode.findUnique({
+ where: { id: episodeId },
+ select: { userId: true, deletedAt: true },
+ });
+ if (!episode || episode.userId !== userId || episode.deletedAt !== null) {
+ return { ok: false, response: apiError("Episode not found", 404) };
+ }
+ return { ok: true };
+}
+
+export const GET = apiHandler(
+ async (request: NextRequest, { params }: RouteParams) => {
+ const { user } = await requireAuth();
+
+ const gate = await requireIllnessEnabled(user.id);
+ if (!gate.enabled) return gate.response;
+
+ const { id } = await params;
+ const owned = await loadOwnedEpisode(id, user.id);
+ if (!owned.ok) return owned.response;
+
+ const parsed = illnessDayLogQuerySchema.safeParse({
+ date: new URL(request.url).searchParams.get("date"),
+ });
+ if (!parsed.success) {
+ return returnAllZodIssues(parsed.error, 422, {
+ errorCode: "illness.day-log.invalid",
+ });
+ }
+
+ const row = await prisma.illnessDayLog.findFirst({
+ where: { episodeId: id, date: parsed.data.date, deletedAt: null },
+ include: dayLogSymptomInclude,
+ });
+
+ annotate({
+ action: {
+ name: "illness.day-log.read",
+ entity_type: "illness_day_log",
+ },
+ meta: { found: row !== null },
+ });
+
+ return apiSuccess(row ? toIllnessDayLogDTO(row) : null);
+ },
+);
+
+export const POST = apiHandler(
+ async (request: NextRequest, { params }: RouteParams) => {
+ const { user } = await requireAuth();
+
+ const gate = await requireIllnessEnabled(user.id);
+ if (!gate.enabled) return gate.response;
+
+ const { id } = await params;
+ const owned = await loadOwnedEpisode(id, user.id);
+ if (!owned.ok) return owned.response;
+
+ const { data: rawBody, error: jsonError } = await safeJson(request, {
+ maxBytes: 16 * 1024,
+ });
+ if (jsonError) return jsonError;
+
+ const parsed = illnessDayLogInputSchema.safeParse(rawBody);
+ if (!parsed.success) {
+ annotate({
+ action: { name: "illness.day-log.validation-failed" },
+ meta: { issue_count: parsed.error.issues.length },
+ });
+ return returnAllZodIssues(parsed.error, 422, {
+ errorCode: "illness.day-log.invalid",
+ });
+ }
+
+ const tz = user.timezone ?? DEFAULT_TIMEZONE;
+ const result = await upsertIllnessDayLog(user.id, id, parsed.data, tz);
+
+ await auditLog("illness.day-log.upsert", {
+ userId: user.id,
+ ipAddress: getClientIp(request),
+ details: { dayLogId: result.id, episodeId: id, existed: result.existed },
+ });
+
+ annotate({
+ action: {
+ name: "illness.day-log.upsert",
+ entity_type: "illness_day_log",
+ entity_id: result.id,
+ },
+ meta: { existed: result.existed },
+ });
+
+ return apiSuccess(result.dto, result.existed ? 200 : 201);
+ },
+);
diff --git a/src/app/api/illness/episodes/[id]/resolve/route.ts b/src/app/api/illness/episodes/[id]/resolve/route.ts
new file mode 100644
index 000000000..e8f38d487
--- /dev/null
+++ b/src/app/api/illness/episodes/[id]/resolve/route.ts
@@ -0,0 +1,101 @@
+/**
+ * `PATCH /api/illness/episodes/{id}/resolve` — mark an episode recovered.
+ *
+ * Sets `resolvedAt` (defaults to "now" when the body omits it). A
+ * dedicated verb so the history surface's one-tap "mark recovered" never
+ * has to send the full edit body. Born-gated + owner-scoped. A
+ * CHRONIC_ONGOING episode has no "recovered" date by design — resolving
+ * one is refused.
+ */
+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 { requireIllnessEnabled } from "@/lib/illness/gate";
+import { illnessEpisodeResolveSchema } from "@/lib/validations/illness";
+import { toIllnessEpisodeDTO } from "@/lib/illness/dto";
+
+type RouteParams = { params: Promise<{ id: string }> };
+
+export const PATCH = apiHandler(
+ async (request: NextRequest, { params }: RouteParams) => {
+ const { user } = await requireAuth();
+
+ const gate = await requireIllnessEnabled(user.id);
+ if (!gate.enabled) return gate.response;
+
+ const { id } = await params;
+ const existing = await prisma.illnessEpisode.findUnique({
+ where: { id },
+ select: {
+ id: true,
+ userId: true,
+ deletedAt: true,
+ lifecycle: true,
+ onsetAt: true,
+ },
+ });
+ if (!existing || existing.userId !== user.id || existing.deletedAt !== null) {
+ return apiError("Episode not found", 404);
+ }
+ if (existing.lifecycle === "CHRONIC_ONGOING") {
+ return apiError("A chronic-ongoing condition has no recovery date", 422, {
+ errorCode: "illness.episode.chronic-no-resolve",
+ });
+ }
+
+ const { data: rawBody, error: jsonError } = await safeJson(request, {
+ maxBytes: 4 * 1024,
+ });
+ if (jsonError) return jsonError;
+
+ const parsed = illnessEpisodeResolveSchema.safeParse(rawBody ?? {});
+ if (!parsed.success) {
+ return returnAllZodIssues(parsed.error, 422, {
+ errorCode: "illness.episode.invalid",
+ });
+ }
+
+ const resolvedAt = parsed.data.resolvedAt
+ ? new Date(parsed.data.resolvedAt)
+ : new Date();
+
+ // An episode can never resolve before it began (the inverted-window guard,
+ // checked against the stored onset since the body only carries resolvedAt).
+ if (resolvedAt < existing.onsetAt) {
+ return apiError("resolvedAt must be on or after onsetAt", 422, {
+ errorCode: "illness.episode.invalid-window",
+ });
+ }
+
+ const updated = await prisma.illnessEpisode.update({
+ where: { id },
+ data: { resolvedAt },
+ });
+
+ await auditLog("illness.episode.resolve", {
+ userId: user.id,
+ ipAddress: getClientIp(request),
+ details: { episodeId: id },
+ });
+
+ annotate({
+ action: {
+ name: "illness.episode.resolve",
+ entity_type: "illness_episode",
+ entity_id: id,
+ },
+ });
+
+ return apiSuccess(toIllnessEpisodeDTO(updated));
+ },
+);
diff --git a/src/app/api/illness/episodes/[id]/route.ts b/src/app/api/illness/episodes/[id]/route.ts
new file mode 100644
index 000000000..3bf355ca8
--- /dev/null
+++ b/src/app/api/illness/episodes/[id]/route.ts
@@ -0,0 +1,204 @@
+/**
+ * `GET /api/illness/episodes/{id}` — one owned, live episode.
+ * `PATCH /api/illness/episodes/{id}` — edit it (partial; field-by-field).
+ * `DELETE /api/illness/episodes/{id}` — soft-delete it (idempotent;
+ * returns `{ deleted: true }`).
+ *
+ * Born-gated + owner-scoped. `userId` is narrowed from auth and fed to the
+ * Prisma `where`; the body never carries it. `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 { requireIllnessEnabled } from "@/lib/illness/gate";
+import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
+import { illnessEpisodeUpdateSchema } from "@/lib/validations/illness";
+import { toIllnessEpisodeDTO } from "@/lib/illness/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 gate = await requireIllnessEnabled(user.id);
+ if (!gate.enabled) return gate.response;
+
+ const { id } = await params;
+ const row = await prisma.illnessEpisode.findUnique({ where: { id } });
+ if (!row || row.userId !== user.id || row.deletedAt !== null) {
+ return apiError("Episode not found", 404);
+ }
+
+ annotate({
+ action: {
+ name: "illness.episode.read",
+ entity_type: "illness_episode",
+ entity_id: id,
+ },
+ });
+
+ return apiSuccess(toIllnessEpisodeDTO(row));
+ },
+);
+
+export const PATCH = apiHandler(
+ async (request: NextRequest, { params }: RouteParams) => {
+ const { user } = await requireAuth();
+
+ const gate = await requireIllnessEnabled(user.id);
+ if (!gate.enabled) return gate.response;
+
+ const { id } = await params;
+ const existing = await prisma.illnessEpisode.findUnique({
+ where: { id },
+ select: {
+ id: true,
+ userId: true,
+ deletedAt: true,
+ onsetAt: true,
+ resolvedAt: true,
+ },
+ });
+ if (!existing || existing.userId !== user.id || existing.deletedAt !== null) {
+ return apiError("Episode not found", 404);
+ }
+
+ const { data: rawBody, error: jsonError } = await safeJson(request, {
+ maxBytes: 16 * 1024,
+ });
+ if (jsonError) return jsonError;
+
+ const parsed = illnessEpisodeUpdateSchema.safeParse(rawBody);
+ if (!parsed.success) {
+ return returnAllZodIssues(parsed.error, 422, {
+ errorCode: "illness.episode.invalid",
+ });
+ }
+ const entry = parsed.data;
+
+ // Window invariant against the MERGED state: a partial edit touching only
+ // one instant is validated against the stored value (the schema already
+ // catches a both-in-body inversion).
+ const mergedOnset =
+ entry.onsetAt !== undefined ? new Date(entry.onsetAt) : existing.onsetAt;
+ const mergedResolved =
+ entry.resolvedAt !== undefined
+ ? entry.resolvedAt
+ ? new Date(entry.resolvedAt)
+ : null
+ : existing.resolvedAt;
+ if (mergedResolved && mergedResolved < mergedOnset) {
+ return apiError("resolvedAt must be on or after onsetAt", 422, {
+ errorCode: "illness.episode.invalid-window",
+ });
+ }
+
+ // A re-parent must point at an owned, live episode.
+ if (entry.parentConditionId) {
+ const parent = await prisma.illnessEpisode.findUnique({
+ where: { id: entry.parentConditionId },
+ select: { userId: true, deletedAt: true },
+ });
+ if (
+ entry.parentConditionId === id ||
+ !parent ||
+ parent.userId !== user.id ||
+ parent.deletedAt !== null
+ ) {
+ return apiError("Parent condition not found", 404, {
+ errorCode: "illness.episode.parent-not-found",
+ });
+ }
+ }
+
+ // Field-by-field — only fields actually present in the body are written.
+ const data: Prisma.IllnessEpisodeUpdateInput = {};
+ if (entry.label !== undefined) data.label = entry.label;
+ if (entry.type !== undefined) data.type = entry.type;
+ if (entry.lifecycle !== undefined) data.lifecycle = entry.lifecycle;
+ if (entry.onsetAt !== undefined) data.onsetAt = new Date(entry.onsetAt);
+ if (entry.resolvedAt !== undefined) {
+ data.resolvedAt = entry.resolvedAt ? new Date(entry.resolvedAt) : null;
+ }
+ if (entry.parentConditionId !== undefined) {
+ data.parent = entry.parentConditionId
+ ? { connect: { id: entry.parentConditionId } }
+ : { disconnect: true };
+ }
+ if (entry.note !== undefined) {
+ data.noteEncrypted = entry.note ? encryptToBytes(entry.note) : null;
+ }
+
+ const updated = await prisma.illnessEpisode.update({ where: { id }, data });
+
+ await auditLog("illness.episode.update", {
+ userId: user.id,
+ ipAddress: getClientIp(request),
+ details: { episodeId: id },
+ });
+
+ annotate({
+ action: {
+ name: "illness.episode.update",
+ entity_type: "illness_episode",
+ entity_id: id,
+ },
+ });
+
+ return apiSuccess(toIllnessEpisodeDTO(updated));
+ },
+);
+
+export const DELETE = apiHandler(
+ async (request: NextRequest, { params }: RouteParams) => {
+ const { user } = await requireAuth();
+
+ const gate = await requireIllnessEnabled(user.id);
+ if (!gate.enabled) return gate.response;
+
+ const { id } = await params;
+ const existing = await prisma.illnessEpisode.findUnique({
+ where: { id },
+ select: { id: true, userId: true, deletedAt: true },
+ });
+ if (!existing || existing.userId !== user.id) {
+ return apiError("Episode not found", 404);
+ }
+
+ if (existing.deletedAt === null) {
+ await prisma.illnessEpisode.update({
+ where: { id },
+ data: { deletedAt: new Date() },
+ });
+ await auditLog("illness.episode.delete", {
+ userId: user.id,
+ ipAddress: getClientIp(request),
+ details: { episodeId: id },
+ });
+ }
+
+ annotate({
+ action: {
+ name: "illness.episode.delete",
+ entity_type: "illness_episode",
+ entity_id: id,
+ },
+ });
+
+ // Standard `{ deleted: true }` envelope (the cross-module DELETE shape) so
+ // the client never has to special-case a bodyless 204 here.
+ return apiSuccess({ deleted: true });
+ },
+);
diff --git a/src/app/api/illness/episodes/route.ts b/src/app/api/illness/episodes/route.ts
new file mode 100644
index 000000000..154e0c880
--- /dev/null
+++ b/src/app/api/illness/episodes/route.ts
@@ -0,0 +1,144 @@
+/**
+ * `GET /api/illness/episodes` — newest-first list of the account's
+ * illness/condition episodes.
+ * `POST /api/illness/episodes` — create one episode.
+ *
+ * The illness journal is a CONDITION journal, retrospective-only — NEVER a
+ * predictor/diagnoser. Born-gated: a non-opted-in account (or an
+ * operator-disabled instance) 403s with `errorCode:"illness.disabled"`
+ * even with a valid Bearer token. `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); `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 { requireIllnessEnabled } from "@/lib/illness/gate";
+import { withIdempotency } from "@/lib/idempotency";
+import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
+import {
+ illnessEpisodeCreateSchema,
+ illnessEpisodeListQuerySchema,
+} from "@/lib/validations/illness";
+import { toIllnessEpisodeDTO } from "@/lib/illness/dto";
+
+export const GET = apiHandler(async (request: NextRequest) => {
+ const { user } = await requireAuth();
+
+ const gate = await requireIllnessEnabled(user.id);
+ if (!gate.enabled) return gate.response;
+
+ const params = new URL(request.url).searchParams;
+ const parsed = illnessEpisodeListQuerySchema.safeParse({
+ limit: params.get("limit") ?? undefined,
+ includeResolved: params.get("includeResolved") ?? undefined,
+ });
+ if (!parsed.success) {
+ return returnAllZodIssues(parsed.error, 422, {
+ errorCode: "illness.episode.invalid",
+ });
+ }
+
+ const limit = parsed.data.limit ?? 50;
+ const rows = await prisma.illnessEpisode.findMany({
+ where: {
+ userId: user.id,
+ deletedAt: null,
+ ...(parsed.data.includeResolved === "false" ? { resolvedAt: null } : {}),
+ },
+ orderBy: { onsetAt: "desc" },
+ take: limit,
+ });
+
+ annotate({
+ action: { name: "illness.episode.list", entity_type: "illness_episode" },
+ meta: { count: rows.length },
+ });
+
+ return apiSuccess(rows.map(toIllnessEpisodeDTO));
+});
+
+// `withIdempotency` lets an iOS retry / double-tap re-send the same
+// `Idempotency-Key` without minting a duplicate episode (siblings — labs,
+// measurements — wrap their creates the same way).
+export const POST = apiHandler(withIdempotency<[NextRequest]>(postEpisode));
+
+async function postEpisode(request: NextRequest): Promise {
+ const { user } = await requireAuth();
+
+ const gate = await requireIllnessEnabled(user.id);
+ if (!gate.enabled) return gate.response;
+
+ const { data: rawBody, error: jsonError } = await safeJson(request, {
+ maxBytes: 16 * 1024,
+ });
+ if (jsonError) return jsonError;
+
+ const parsed = illnessEpisodeCreateSchema.safeParse(rawBody);
+ if (!parsed.success) {
+ annotate({
+ action: { name: "illness.episode.validation-failed" },
+ meta: { issue_count: parsed.error.issues.length },
+ });
+ return returnAllZodIssues(parsed.error, 422, {
+ errorCode: "illness.episode.invalid",
+ });
+ }
+
+ const entry = parsed.data;
+
+ // A FLARE/RECURRING bout may hang off a parent condition — verify the
+ // parent is owned + live before threading.
+ if (entry.parentConditionId) {
+ const parent = await prisma.illnessEpisode.findUnique({
+ where: { id: entry.parentConditionId },
+ select: { userId: true, deletedAt: true },
+ });
+ if (!parent || parent.userId !== user.id || parent.deletedAt !== null) {
+ return apiError("Parent condition not found", 404, {
+ errorCode: "illness.episode.parent-not-found",
+ });
+ }
+ }
+
+ // Field-by-field — never spread the parsed object whole.
+ const created = await prisma.illnessEpisode.create({
+ data: {
+ userId: user.id,
+ label: entry.label,
+ type: entry.type,
+ lifecycle: entry.lifecycle,
+ onsetAt: entry.onsetAt ? new Date(entry.onsetAt) : new Date(),
+ resolvedAt: entry.resolvedAt ? new Date(entry.resolvedAt) : null,
+ parentConditionId: entry.parentConditionId ?? null,
+ noteEncrypted: entry.note ? encryptToBytes(entry.note) : null,
+ },
+ });
+
+ await auditLog("illness.episode.create", {
+ userId: user.id,
+ ipAddress: getClientIp(request),
+ details: { episodeId: created.id, type: created.type },
+ });
+
+ annotate({
+ action: {
+ name: "illness.episode.create",
+ entity_type: "illness_episode",
+ entity_id: created.id,
+ },
+ meta: { type: created.type, lifecycle: created.lifecycle },
+ });
+
+ return apiSuccess(toIllnessEpisodeDTO(created), 201);
+}
diff --git a/src/app/api/illness/insights/route.ts b/src/app/api/illness/insights/route.ts
new file mode 100644
index 000000000..48960abdd
--- /dev/null
+++ b/src/app/api/illness/insights/route.ts
@@ -0,0 +1,107 @@
+/**
+ * `GET /api/illness/insights?windowDays=365` — the cross-episode retrospective
+ * summary: "sick N times this year · typical recovery gap X days", with a
+ * recurrence-by-month tally and a per-type breakdown.
+ *
+ * RETROSPECTIVE ONLY — it summarises past episodes. It is NEVER a predictor or
+ * diagnoser; the recurrence figure is a count, not a forecast. Server-
+ * authoritative + gated: the "typical gap" is withheld below the min-sample
+ * floor (asserts nothing thin). Per-episode gaps come from the same coverage-
+ * gated correlation engine the per-episode route uses, so a thin episode
+ * contributes no gap rather than a wrong one. Born-gated + owner-scoped.
+ */
+import { NextRequest } from "next/server";
+
+import { prisma } from "@/lib/db";
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import { annotate } from "@/lib/logging/context";
+import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
+import { requireIllnessEnabled } from "@/lib/illness/gate";
+import { illnessInsightsQuerySchema } from "@/lib/validations/illness";
+import { computeEpisodeCorrelation } from "@/lib/illness/correlation-read";
+import {
+ summarizeIllnessRetrospective,
+ type RetrospectiveEpisode,
+} from "@/lib/illness/retrospective";
+import { dayKeyForUserTz } from "@/lib/measurements/consolidation-tz";
+import { resolveUserTimezone } from "@/lib/measurements/consolidation-base";
+
+const MS_PER_DAY = 24 * 60 * 60 * 1000;
+/** Cap the per-episode gap computations so a heavy history can't fan out. */
+const MAX_GAP_COMPUTATIONS = 24;
+
+export const GET = apiHandler(async (request: NextRequest) => {
+ const { user } = await requireAuth();
+
+ const gate = await requireIllnessEnabled(user.id);
+ if (!gate.enabled) return gate.response;
+
+ const parsed = illnessInsightsQuerySchema.safeParse({
+ windowDays: new URL(request.url).searchParams.get("windowDays") ?? undefined,
+ });
+ if (!parsed.success) {
+ return returnAllZodIssues(parsed.error, 422, {
+ errorCode: "illness.insights.invalid",
+ });
+ }
+
+ const windowDays = parsed.data.windowDays ?? 365;
+ const now = new Date();
+ const since = new Date(now.getTime() - windowDays * MS_PER_DAY);
+ const tz = resolveUserTimezone(user.timezone ?? null);
+
+ const rows = await prisma.illnessEpisode.findMany({
+ where: { userId: user.id, deletedAt: null, onsetAt: { gte: since } },
+ orderBy: { onsetAt: "desc" },
+ select: {
+ id: true,
+ type: true,
+ lifecycle: true,
+ onsetAt: true,
+ resolvedAt: true,
+ },
+ });
+
+ // Compute the recovery-gap only for resolved, non-chronic episodes (the
+ // only ones that CAN have a gap), bounded to MAX_GAP_COMPUTATIONS most
+ // recent. Each gap comes from the coverage-gated engine — a thin episode
+ // yields null and simply doesn't contribute to the typical-gap median.
+ const gapCandidates = rows
+ .filter((r) => r.resolvedAt !== null && r.lifecycle !== "CHRONIC_ONGOING")
+ .slice(0, MAX_GAP_COMPUTATIONS);
+ const gapById = new Map();
+ for (const r of gapCandidates) {
+ const derived = await computeEpisodeCorrelation(
+ user.id,
+ { id: r.id, onsetAt: r.onsetAt, resolvedAt: r.resolvedAt, lifecycle: r.lifecycle },
+ tz,
+ now,
+ );
+ gapById.set(
+ r.id,
+ derived.status === "ok" ? derived.value.recoveryGapDays : null,
+ );
+ }
+
+ const episodes: RetrospectiveEpisode[] = rows.map((r) => ({
+ id: r.id,
+ type: r.type,
+ onsetDay: dayKeyForUserTz(r.onsetAt, tz),
+ resolved: r.resolvedAt !== null,
+ recoveryGapDays: gapById.get(r.id) ?? null,
+ lifecycle: r.lifecycle,
+ }));
+
+ const summary = summarizeIllnessRetrospective(episodes);
+
+ annotate({
+ action: { name: "illness.insights.read", entity_type: "illness_episode" },
+ meta: {
+ episode_count: summary.episodeCount,
+ gap_sample_size: summary.gapSampleSize,
+ typical_gap_days: summary.typicalRecoveryGapDays,
+ },
+ });
+
+ return apiSuccess({ windowDays, ...summary });
+});
diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts
index df06d2c80..812c38b80 100644
--- a/src/app/api/insights/chat/route.ts
+++ b/src/app/api/insights/chat/route.ts
@@ -69,7 +69,13 @@ import { getCoachSystemPrompt } from "@/lib/ai/coach/system-prompt";
import { getSelfContextTextForUser } from "@/lib/ai/coach/about-me";
import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot";
import { parseKeyValuesSentinel } from "@/lib/ai/coach/keyvalues";
-import { parseCoachPrefs } from "@/lib/validations/coach-prefs";
+import { parseSuggestReminder } from "@/lib/ai/coach/suggest-reminder";
+import { gateSuggestion } from "@/lib/ai/coach/suggest-gate";
+import {
+ parseCoachPrefs,
+ DEFAULT_REMINDER_SUGGESTION_PREFS,
+} from "@/lib/validations/coach-prefs";
+import type { CoachSuggestion } from "@/lib/ai/coach/types";
import { createSseStream } from "@/lib/sse/create-stream";
/**
@@ -498,11 +504,66 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
});
return streamProviderError({ code: "coach.provider.empty" });
}
- const replyText = proseAfterStrip;
- const enrichedProvenance =
- sentinel.keyValues.length > 0
- ? { ...snapshot.provenance, keyValues: sentinel.keyValues }
- : snapshot.provenance;
+ // v1.18.1 (Workstream C) — strip the optional `---SUGGEST-REMINDER---`
+ // block out of the prose-after-keyvalues. The model proposes a cadence;
+ // the gate decides whether it actually surfaces (module-toggle + opt-out
+ // + dismissal memory + cooldown + dedup against a live COACH reminder).
+ // A suppressed proposal leaves the prose unchanged and emits no card.
+ const suggestParse = parseSuggestReminder(proseAfterStrip);
+ const replyText = suggestParse.prose.trim() || proseAfterStrip;
+ let surfacedSuggestion: CoachSuggestion | null = null;
+ if (suggestParse.cadence) {
+ const decision = await gateSuggestion({
+ prisma,
+ userId,
+ cadence: suggestParse.cadence,
+ prefs: coachPrefs.reminderSuggestions ?? DEFAULT_REMINDER_SUGGESTION_PREFS,
+ });
+ if (decision.surface) {
+ const cadence = suggestParse.cadence;
+ surfacedSuggestion = {
+ cadenceId: cadence.id,
+ measurementType: cadence.measurementType,
+ label: cadence.labelKey,
+ };
+ // Stamp the cooldown anchor (frequency cap) onto the prefs blob.
+ const nextSuggestionPrefs = {
+ ...(coachPrefs.reminderSuggestions ??
+ DEFAULT_REMINDER_SUGGESTION_PREFS),
+ lastSuggestedAt: new Date().toISOString(),
+ };
+ void prisma.user
+ .update({
+ where: { id: userId },
+ data: {
+ coachPrefsJson: {
+ ...coachPrefs,
+ reminderSuggestions: nextSuggestionPrefs,
+ },
+ },
+ })
+ .catch(() => {
+ // Cooldown stamp is best-effort: a write failure at worst lets a
+ // second suggestion through sooner, never breaks the chat turn.
+ });
+ annotate({
+ action: { name: "coach.reminder.suggested" },
+ meta: { cadenceId: cadence.id, metric: cadence.measurementType },
+ });
+ } else {
+ annotate({
+ action: { name: "coach.reminder.suppressed" },
+ meta: { cadenceId: suggestParse.cadence.id, reason: decision.reason },
+ });
+ }
+ }
+ const enrichedProvenance: typeof snapshot.provenance = {
+ ...snapshot.provenance,
+ ...(sentinel.keyValues.length > 0
+ ? { keyValues: sentinel.keyValues }
+ : {}),
+ ...(surfacedSuggestion ? { suggestion: surfacedSuggestion } : {}),
+ };
if (sentinel.malformed) {
// Graceful degrade: log so ops can spot a provider whose
// sentinel format has drifted, but pass the prose through
@@ -585,6 +646,13 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
metricSource: enrichedProvenance,
}),
);
+ // v1.18.1 (Workstream C) — additive `suggestion` frame. Older clients
+ // ignore it; newer ones render the one-tap action card.
+ if (surfacedSuggestion) {
+ controller.enqueue(
+ encodeFrame({ type: "suggestion", suggestion: surfacedSuggestion }),
+ );
+ }
controller.enqueue(
encodeFrame({
type: "done",
diff --git a/src/app/api/insights/metric-status/route.ts b/src/app/api/insights/metric-status/route.ts
index e6b62286e..d27e6a20f 100644
--- a/src/app/api/insights/metric-status/route.ts
+++ b/src/app/api/insights/metric-status/route.ts
@@ -49,8 +49,18 @@ export const dynamic = "force-dynamic";
const METRIC_MODULE: Partial> = {
SLEEP_DURATION: "sleep",
BREATHING_DISTURBANCES: "sleep",
+ SLEEP_SCORE: "sleep",
BLOOD_GLUCOSE: "glucose",
CARDIO_RECOVERY: "recovery",
+ // v1.18.1 — device-native recovery / strain signals gate on the
+ // recovery module like CARDIO_RECOVERY.
+ ANS_CHARGE: "recovery",
+ DAY_STRAIN: "recovery",
+ WORKOUT_STRAIN: "recovery",
+ CARDIO_LOAD: "recovery",
+ AVERAGE_HEART_RATE: "recovery",
+ MAX_HEART_RATE: "recovery",
+ ENERGY_EXPENDITURE_KJ: "recovery",
};
// Closed enum derived from the registry so the route + registry cannot
diff --git a/src/app/api/labs/[id]/route.ts b/src/app/api/labs/[id]/route.ts
index 829f88498..39bd061e5 100644
--- a/src/app/api/labs/[id]/route.ts
+++ b/src/app/api/labs/[id]/route.ts
@@ -11,10 +11,16 @@ import {
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
+import {
+ type ResolvedBiomarker,
+ serialiseLabResult,
+ serialiseLabResultDetail,
+} from "@/lib/labs/serialise";
import { decryptNoteFromBytes, encryptNoteToBytes } from "@/lib/labs/store";
import { annotate } from "@/lib/logging/context";
import {
- classifyReferenceRange,
+ effectiveBound,
+ isInvertedRange,
updateLabResultSchema,
} from "@/lib/validations/labs";
@@ -26,10 +32,36 @@ import {
* (`data` built field-by-field; an explicit `null` clears `panel` / `note` /
* a reference bound, an omitted key leaves it untouched). DELETE soft-deletes
* by stamping `deletedAt`. Cross-user rows surface as 404 (existence sealed).
+ *
+ * v1.18.1 — when the row links a `Biomarker`, the response resolves the
+ * canonical name / unit / range from the catalog (server-authoritative). The
+ * legacy per-row free-text fields stay editable for an unlinked row.
*/
type RouteParams = { params: Promise<{ id: string }> };
+const biomarkerSelect = {
+ id: true,
+ name: true,
+ unit: true,
+ lowerBound: true,
+ upperBound: true,
+ panel: true,
+} as const;
+
+function toResolved(
+ bm: {
+ id: string;
+ name: string;
+ unit: string;
+ lowerBound: number | null;
+ upperBound: number | null;
+ panel: string | null;
+ } | null,
+): ResolvedBiomarker | null {
+ return bm ?? null;
+}
+
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
@@ -37,6 +69,7 @@ export const GET = apiHandler(
const row = await prisma.labResult.findFirst({
where: { id, deletedAt: null },
+ include: { biomarker: { select: biomarkerSelect } },
});
if (!row || row.userId !== user.id) {
return apiError("Lab result not found", 404);
@@ -44,27 +77,13 @@ export const GET = apiHandler(
annotate({ action: { name: "labs.get" }, meta: { labResultId: id } });
- return apiSuccess({
- id: row.id,
- panel: row.panel,
- analyte: row.analyte,
- value: row.value,
- unit: row.unit,
- referenceLow: row.referenceLow,
- referenceHigh: row.referenceHigh,
- takenAt: row.takenAt.toISOString(),
- source: row.source,
- note: row.noteEncrypted
- ? decryptNoteFromBytes(row.noteEncrypted)
- : null,
- rangeStatus: classifyReferenceRange(
- row.value,
- row.referenceLow,
- row.referenceHigh,
+ return apiSuccess(
+ serialiseLabResultDetail(
+ row,
+ toResolved(row.biomarker),
+ row.noteEncrypted ? decryptNoteFromBytes(row.noteEncrypted) : null,
),
- createdAt: row.createdAt.toISOString(),
- updatedAt: row.updatedAt.toISOString(),
- });
+ );
},
);
@@ -110,8 +129,31 @@ export const PUT = apiHandler(
const d = parsed.data;
+ // A reading linked to a catalog marker resolves its name / unit / range
+ // FROM the biomarker (server-authoritative). Editing those catalog-owned
+ // fields on a linked row is meaningless — they would persist as stale
+ // historical columns the serialiser ignores. Reject the attempt with a
+ // 422 rather than silently no-op, so the client never believes an edit
+ // landed. Edit such fields on the Biomarker itself instead.
+ const isLinked = existing.biomarkerId !== null;
+ if (
+ isLinked &&
+ (d.analyte !== undefined ||
+ d.unit !== undefined ||
+ d.panel !== undefined ||
+ d.referenceLow !== undefined ||
+ d.referenceHigh !== undefined)
+ ) {
+ return apiError(
+ "analyte / unit / panel / reference range are resolved from the linked biomarker and cannot be edited on a linked reading",
+ 422,
+ );
+ }
+
// Build `data` field-by-field. `undefined` → leave the column untouched;
// an explicit `null` on `panel` / `note` / a reference bound clears it.
+ // A reading linked to a catalog marker is edited via VALUE / takenAt /
+ // note here; its name / unit / range stay resolved from the biomarker.
const data: Record = {};
if (d.panel !== undefined) data.panel = d.panel;
if (d.analyte !== undefined) data.analyte = d.analyte;
@@ -120,11 +162,30 @@ export const PUT = apiHandler(
if (d.referenceLow !== undefined) data.referenceLow = d.referenceLow;
if (d.referenceHigh !== undefined) data.referenceHigh = d.referenceHigh;
if (d.takenAt !== undefined) data.takenAt = d.takenAt;
+
+ // Inverted-range guard for a PARTIAL bound update. The schema-level
+ // refine only fires when both bounds arrive in one request; a request
+ // that moves a single bound below/above the row's existing other bound
+ // would otherwise persist an inverted range. Merge the effective bounds
+ // (parsed value when present, else the stored value) and 422 when both
+ // resolve to concrete numbers with low > high.
+ if (
+ isInvertedRange(
+ effectiveBound(d.referenceLow, existing.referenceLow),
+ effectiveBound(d.referenceHigh, existing.referenceHigh),
+ )
+ ) {
+ return apiError("referenceLow must not exceed referenceHigh", 422);
+ }
if (d.note !== undefined) {
data.noteEncrypted = d.note ? encryptNoteToBytes(d.note) : null;
}
- const updated = await prisma.labResult.update({ where: { id }, data });
+ const updated = await prisma.labResult.update({
+ where: { id },
+ data,
+ include: { biomarker: { select: biomarkerSelect } },
+ });
await auditLog("labResult.update", {
userId: user.id,
@@ -134,25 +195,9 @@ export const PUT = apiHandler(
annotate({ action: { name: "labs.update" }, meta: { labResultId: id } });
- return apiSuccess({
- id: updated.id,
- panel: updated.panel,
- analyte: updated.analyte,
- value: updated.value,
- unit: updated.unit,
- referenceLow: updated.referenceLow,
- referenceHigh: updated.referenceHigh,
- takenAt: updated.takenAt.toISOString(),
- source: updated.source,
- hasNote: updated.noteEncrypted !== null,
- rangeStatus: classifyReferenceRange(
- updated.value,
- updated.referenceLow,
- updated.referenceHigh,
- ),
- createdAt: updated.createdAt.toISOString(),
- updatedAt: updated.updatedAt.toISOString(),
- });
+ return apiSuccess(
+ serialiseLabResult(updated, toResolved(updated.biomarker)),
+ );
},
);
@@ -178,7 +223,7 @@ export const DELETE = apiHandler(
await auditLog("labResult.delete", {
userId: user.id,
ipAddress: getClientIp(request),
- details: { labResultId: id, analyte: existing.analyte },
+ details: { labResultId: id },
});
annotate({ action: { name: "labs.delete" }, meta: { labResultId: id } });
diff --git a/src/app/api/labs/restore/route.ts b/src/app/api/labs/restore/route.ts
new file mode 100644
index 000000000..07d0bc409
--- /dev/null
+++ b/src/app/api/labs/restore/route.ts
@@ -0,0 +1,73 @@
+/**
+ * `POST /api/labs/restore` — un-tombstone for the lab-history delete-Undo
+ * affordance (v1.18.1).
+ *
+ * Body: `{ ids: string[] }` — 1..200 lab-result ids, scoped to the caller.
+ *
+ * Clears `deletedAt` on every owned, currently-tombstoned row in one
+ * `updateMany`, so the reading re-surfaces in normal reads. A forged /
+ * foreign / not-deleted id is a silent no-op (never a 404 existence leak):
+ * the mutation `where` pins `userId` so it only ever touches the caller's
+ * rows. Mirrors `/api/measurements/restore`.
+ */
+import { NextRequest } from "next/server";
+import { z } from "zod/v4";
+
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import {
+ apiError,
+ apiSuccess,
+ returnAllZodIssues,
+ safeJson,
+} from "@/lib/api-response";
+import { prisma } from "@/lib/db";
+import { withIdempotency } from "@/lib/idempotency";
+import { annotate } from "@/lib/logging/context";
+import { checkRateLimit } from "@/lib/rate-limit";
+
+const MAX_IDS_PER_BATCH = 200;
+const RATE_LIMIT_MAX = 60;
+const RATE_LIMIT_WINDOW_MS = 60 * 1000;
+
+const restoreSchema = z.object({
+ ids: z.array(z.string().min(1)).min(1).max(MAX_IDS_PER_BATCH),
+});
+
+export const POST = apiHandler(withIdempotency<[NextRequest]>(postRestore));
+
+async function postRestore(request: NextRequest): Promise {
+ const { user } = await requireAuth();
+
+ const rl = await checkRateLimit(
+ `labs:restore:${user.id}`,
+ RATE_LIMIT_MAX,
+ RATE_LIMIT_WINDOW_MS,
+ );
+ if (!rl.allowed) {
+ return apiError("Too many restore requests, try again later", 429);
+ }
+
+ const { data: rawBody, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
+ if (jsonError) return jsonError;
+
+ const parsed = restoreSchema.safeParse(rawBody);
+ if (!parsed.success) {
+ return returnAllZodIssues(parsed.error, 422, {
+ errorCode: "labs.restore.invalid",
+ });
+ }
+
+ const { ids } = parsed.data;
+
+ // `userId`-scoped + `deletedAt != null`: a foreign / live id is skipped.
+ const { count } = await prisma.labResult.updateMany({
+ where: { id: { in: ids }, userId: user.id, deletedAt: { not: null } },
+ data: { deletedAt: null },
+ });
+
+ annotate({ action: { name: "labs.restore" }, meta: { restored: count } });
+
+ return apiSuccess({ restored: count });
+}
diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts
index a80ff3f41..5afb9ac69 100644
--- a/src/app/api/labs/route.ts
+++ b/src/app/api/labs/route.ts
@@ -2,6 +2,7 @@ import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
+ apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
@@ -11,10 +12,15 @@ import {
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { withIdempotency } from "@/lib/idempotency";
+import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
+import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store";
+import {
+ type ResolvedBiomarker,
+ serialiseLabResult,
+} from "@/lib/labs/serialise";
import { encryptNoteToBytes } from "@/lib/labs/store";
import { annotate } from "@/lib/logging/context";
import {
- classifyReferenceRange,
createLabResultSchema,
listLabResultsSchema,
} from "@/lib/validations/labs";
@@ -22,49 +28,42 @@ import {
/**
* v1.17.1 — structured lab-result store (`/api/labs`).
*
- * GET lists the caller's live results with optional analyte / panel / date
- * filters. POST records a single reading. `userId` is always narrowed from
- * the session — never a body field — and the write `data` object is built
- * field-by-field (no mass assignment). The free-text note, when present, is
- * AES-256-GCM encrypted into the `noteEncrypted` Bytes column before write.
+ * Module gate: unlike `/api/illness/*` (born-gated, every route 403s when the
+ * module is off), Labs is intentionally NOT server-gated. The Labs module
+ * toggle is a UX-only nav/visibility preference — the data is always
+ * owner-scoped and safe to read/write, and the Vorsorge lab-panel reminder
+ * + doctor-report PDF read these rows regardless of the nav toggle. Gating
+ * here would 403 those legitimate cross-feature reads. Deliberate opt-out.
+ *
+ * GET lists the caller's live results with optional biomarker / analyte /
+ * panel / date filters. POST records a single reading. `userId` is always
+ * narrowed from the session — never a body field — and the write `data`
+ * object is built field-by-field (no mass assignment). The free-text note,
+ * when present, is AES-256-GCM encrypted into the `noteEncrypted` Bytes
+ * column before write.
+ *
+ * v1.18.1 — structured entry: when the body carries a `biomarkerId`, the row
+ * links the user-scoped catalog marker and the response resolves its unit +
+ * reference range FROM the biomarker (server-authoritative). A free-text body
+ * (no `biomarkerId`) resolves-or-mints a catalog marker by
+ * `(userId, lower(analyte))`, so NO row ever persists unlinked — every reading
+ * is editable + detail-navigable, and the boot-time backfill becomes a pure
+ * pre-upgrade migration. The web + iOS clients render the resolved DTO and
+ * never recompute.
*/
-// The serialised row never echoes the encrypted note bytes back; the badge
-// status is computed server-side so the client renders a coherent, neutral
-// in/out-of-range verdict without re-deriving the rule.
-function serialiseLabResult(row: {
- id: string;
- panel: string | null;
- analyte: string;
- value: number;
- unit: string;
- referenceLow: number | null;
- referenceHigh: number | null;
- takenAt: Date;
- source: string;
- noteEncrypted: Uint8Array | null;
- createdAt: Date;
- updatedAt: Date;
-}) {
- return {
- id: row.id,
- panel: row.panel,
- analyte: row.analyte,
- value: row.value,
- unit: row.unit,
- referenceLow: row.referenceLow,
- referenceHigh: row.referenceHigh,
- takenAt: row.takenAt.toISOString(),
- source: row.source,
- hasNote: row.noteEncrypted !== null,
- rangeStatus: classifyReferenceRange(
- row.value,
- row.referenceLow,
- row.referenceHigh,
- ),
- createdAt: row.createdAt.toISOString(),
- updatedAt: row.updatedAt.toISOString(),
- };
+/** Map the joined biomarker (or null) into the resolver's shape. */
+function toResolved(
+ bm: {
+ id: string;
+ name: string;
+ unit: string;
+ lowerBound: number | null;
+ upperBound: number | null;
+ panel: string | null;
+ } | null,
+): ResolvedBiomarker | null {
+ return bm ?? null;
}
export const GET = apiHandler(async (request: NextRequest) => {
@@ -80,11 +79,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
return returnAllZodIssues(parsed.error, 422);
}
- const { analyte, panel, from, to, limit, offset, sortDir } = parsed.data;
+ const { biomarkerId, analyte, panel, from, to, limit, offset, sortDir } =
+ parsed.data;
const where = {
userId: user.id,
deletedAt: null,
+ ...(biomarkerId && { biomarkerId }),
...(analyte && { analyte }),
...(panel && { panel }),
...(from || to
@@ -103,6 +104,18 @@ export const GET = apiHandler(async (request: NextRequest) => {
orderBy: { takenAt: sortDir },
take: limit,
skip: offset,
+ include: {
+ biomarker: {
+ select: {
+ id: true,
+ name: true,
+ unit: true,
+ lowerBound: true,
+ upperBound: true,
+ panel: true,
+ },
+ },
+ },
}),
prisma.labResult.count({ where }),
]);
@@ -113,7 +126,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
return apiSuccess({
- results: rows.map(serialiseLabResult),
+ results: rows.map((row) =>
+ serialiseLabResult(row, toResolved(row.biomarker)),
+ ),
meta: { total, limit, offset },
});
});
@@ -153,20 +168,66 @@ async function postLabResult(request: NextRequest) {
return returnAllZodIssues(parsed.error, 422);
}
- const { panel, analyte, value, unit, referenceLow, referenceHigh, takenAt, note } =
- parsed.data;
+ const {
+ biomarkerId,
+ panel,
+ analyte,
+ value,
+ unit,
+ referenceLow,
+ referenceHigh,
+ takenAt,
+ note,
+ } = parsed.data;
- // Field-by-field assignment — never spread `parsed.data`. `source` is
- // hardcoded "MANUAL" for this user-facing path; import paths set their own.
+ // Every row links a catalog marker — no `LabResult` ever persists unlinked
+ // (v1.18.1 High). Two paths converge on a `biomarker`:
+ // - Structured: a `biomarkerId` is supplied; resolve + verify ownership
+ // (a forged / foreign id is a 404).
+ // - Free-text: no `biomarkerId`; resolve-or-mint the marker by
+ // `(userId, lower(analyte))` so the legacy quick-capture path still
+ // yields a linked, editable, detail-navigable reading.
+ let biomarker: ResolvedBiomarker | null = null;
+ if (biomarkerId) {
+ const found = await prisma.biomarker.findFirst({
+ where: { id: biomarkerId, userId: user.id },
+ select: {
+ id: true,
+ name: true,
+ unit: true,
+ lowerBound: true,
+ upperBound: true,
+ panel: true,
+ },
+ });
+ if (!found) {
+ return apiError("Biomarker not found", 404);
+ }
+ biomarker = found;
+ } else {
+ biomarker = await resolveOrMintBiomarker(user.id, {
+ analyte: analyte as string,
+ unit: unit as string,
+ referenceLow: referenceLow ?? null,
+ referenceHigh: referenceHigh ?? null,
+ panel: panel ?? null,
+ });
+ }
+
+ // Field-by-field assignment — never spread `parsed.data`. The row stamps the
+ // resolved name/unit/range as historical truth (so a later catalog edit does
+ // not silently rewrite a past reading) AND keeps the FK; reads resolve the
+ // CURRENT catalog values via `serialise`.
const created = await prisma.labResult.create({
data: {
userId: user.id,
- panel: panel ?? null,
- analyte,
+ biomarkerId: biomarker.id,
+ panel: biomarker.panel,
+ analyte: biomarker.name,
value,
- unit,
- referenceLow: referenceLow ?? null,
- referenceHigh: referenceHigh ?? null,
+ unit: biomarker.unit,
+ referenceLow: biomarker.lowerBound,
+ referenceHigh: biomarker.upperBound,
takenAt,
source: "MANUAL",
noteEncrypted: note ? encryptNoteToBytes(note) : null,
@@ -176,13 +237,24 @@ async function postLabResult(request: NextRequest) {
await auditLog("labResult.create", {
userId: user.id,
ipAddress: getClientIp(request),
- details: { labResultId: created.id, analyte: created.analyte },
+ details: { labResultId: created.id },
});
annotate({
action: { name: "labs.create" },
- meta: { labResultId: created.id },
+ meta: {
+ labResultId: created.id,
+ biomarkerId: biomarker.id,
+ // Whether the client picked a catalog marker (structured) vs the
+ // free-text path that resolved-or-minted one server-side.
+ structured: biomarkerId !== undefined,
+ },
});
- return apiSuccess(serialiseLabResult(created), 201);
+ // v1.18.1 (D2) — eventful Lab↔Vorsorge satisfaction. A lab panel just
+ // landed; resolve the user's free-text "annual blood panel" reminders
+ // now rather than waiting on the 15-min cron. Fire-and-forget.
+ void enqueueReminderSatisfy(user.id).catch(() => {});
+
+ return apiSuccess(serialiseLabResult(created, biomarker), 201);
}
diff --git a/src/app/api/measurement-reminders/[id]/route.ts b/src/app/api/measurement-reminders/[id]/route.ts
index 5813cca22..b163194a6 100644
--- a/src/app/api/measurement-reminders/[id]/route.ts
+++ b/src/app/api/measurement-reminders/[id]/route.ts
@@ -147,6 +147,9 @@ export const PATCH = apiHandler(
(updateData.notifyHour as number | undefined) ?? existing.notifyHour,
lastSatisfiedAt: existing.lastSatisfiedAt,
createdAt: existing.createdAt,
+ // v1.18.1 — preserve the course window so an edit of a finite
+ // (Coach-suggested) cadence keeps self-expiring.
+ endsOn: existing.endsOn,
};
const after =
existing.lastSatisfiedAt && existing.lastSatisfiedAt > now
diff --git a/src/app/api/measurement-reminders/[id]/satisfy/route.ts b/src/app/api/measurement-reminders/[id]/satisfy/route.ts
index 574a88edd..95233b768 100644
--- a/src/app/api/measurement-reminders/[id]/satisfy/route.ts
+++ b/src/app/api/measurement-reminders/[id]/satisfy/route.ts
@@ -13,10 +13,7 @@ import { auditLog } from "@/lib/auth/audit";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
-import {
- computeReminderNextDueAt,
- type ReminderScheduleInput,
-} from "@/lib/measurement-reminders/scheduling";
+import { satisfyReminder } from "@/lib/measurement-reminders/satisfy";
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
type RouteParams = { params: Promise<{ id: string }> };
@@ -42,19 +39,14 @@ export const POST = apiHandler(
const timezone = userRow?.timezone || DEFAULT_TIMEZONE;
const now = new Date();
- const scheduleInput: ReminderScheduleInput = {
- intervalDays: existing.intervalDays,
- rrule: existing.rrule,
- anchorDate: existing.anchorDate,
- notifyHour: existing.notifyHour,
- lastSatisfiedAt: now,
- createdAt: existing.createdAt,
- };
- const nextDueAt = computeReminderNextDueAt(scheduleInput, timezone, now);
+ // The ONE shared satisfaction primitive — same code the cron
+ // auto-resolve and the eventful worker use. A manual "Erledigt" is
+ // always strictly after any prior satisfy, so the forward-only guard
+ // advances it.
+ await satisfyReminder(prisma, existing, timezone, now);
- const updated = await prisma.measurementReminder.update({
+ const updated = await prisma.measurementReminder.findUniqueOrThrow({
where: { id },
- data: { lastSatisfiedAt: now, nextDueAt },
});
await auditLog("measurementReminder.satisfy", {
diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts
index 16aea90fe..849d78c5a 100644
--- a/src/app/api/measurements/batch/route.ts
+++ b/src/app/api/measurements/batch/route.ts
@@ -52,6 +52,7 @@ import { validateMeasurementRange } from "@/lib/validations/measurement";
import { deviceTypeEnum } from "@/lib/validations/source-priority";
import { checkRateLimit } from "@/lib/rate-limit";
import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
+import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import {
@@ -599,6 +600,10 @@ async function postBatch(request: NextRequest): Promise {
// personal record.
if (insertedCount > 0 || updatedCount > 0 || duplicateCount > 0) {
const silent = entries.length > PR_DETECTION_SILENT_THRESHOLD;
+ // v1.18.1 — eventful Vorsorge satisfaction. A matching reading just
+ // landed; resolve the user's reminders now rather than waiting on the
+ // 15-min cron. Fire-and-forget — the cron reconciles on enqueue miss.
+ void enqueueReminderSatisfy(user.id).catch(() => {});
try {
await enqueuePrDetection(user.id, { silent });
await auditLog("personal_records.detection_enqueued", {
diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts
index 4490aaa3d..57f78876c 100644
--- a/src/app/api/measurements/route.ts
+++ b/src/app/api/measurements/route.ts
@@ -29,6 +29,7 @@ import {
import { withIdempotency } from "@/lib/idempotency";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
+import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
import {
recomputeBucketsForMeasurement,
collapseToTypeDayKeys,
@@ -924,6 +925,10 @@ async function postMeasurement(request: NextRequest) {
// SWR readers don't serve the pre-write body back to the user.
invalidateUserMeasurements(user.id, { evict: true });
+ // v1.18.1 — eventful Vorsorge satisfaction. Resolve the user's
+ // reminders against the just-landed readings now. Fire-and-forget.
+ void enqueueReminderSatisfy(user.id).catch(() => {});
+
// v1.5.0 — refresh the persistent rollup table for every distinct
// (type, day) the batch touched so the next analytics / coach read
// hits the cache rather than falling through to live aggregation.
@@ -1037,6 +1042,10 @@ async function postMeasurement(request: NextRequest) {
// pre-write body back to the user.
invalidateUserMeasurements(user.id, { evict: true });
+ // v1.18.1 — eventful Vorsorge satisfaction. Resolve the user's reminders
+ // against the just-landed reading now. Fire-and-forget.
+ void enqueueReminderSatisfy(user.id).catch(() => {});
+
// v1.5.0 — refresh the persistent rollup row for the affected
// (type, day) tuple. Runs inline so the next read of the
// comprehensive / analytics surface is correct without waiting on
diff --git a/src/app/coach/page.tsx b/src/app/coach/page.tsx
index 77e3efe36..23363d12e 100644
--- a/src/app/coach/page.tsx
+++ b/src/app/coach/page.tsx
@@ -61,7 +61,14 @@ export default function CoachPage() {
return (
;
+}) {
+ const { id } = use(params);
+ const { user, isAuthenticated, isLoading } = useAuth();
+ const mounted = useMounted();
+ const router = useRouter();
+ const { t } = useTranslations();
+
+ const enabled = user?.modules?.illness === true;
+
+ useEffect(() => {
+ if (isLoading) return;
+ if (!isAuthenticated) {
+ router.push("/auth/login");
+ } else if (!enabled) {
+ router.push("/");
+ }
+ }, [isLoading, isAuthenticated, enabled, router]);
+
+ if (!mounted || isLoading || !isAuthenticated || !enabled) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ );
+}
diff --git a/src/app/illness/page.tsx b/src/app/illness/page.tsx
new file mode 100644
index 000000000..ec16578b3
--- /dev/null
+++ b/src/app/illness/page.tsx
@@ -0,0 +1,46 @@
+"use client";
+
+import { useEffect } from "react";
+import { useRouter } from "next/navigation";
+import { Loader2 } from "lucide-react";
+
+import { useAuth } from "@/hooks/use-auth";
+import { IllnessView } from "@/components/illness/illness-view";
+
+/**
+ * v1.18.1 — the illness / condition-journal entry.
+ *
+ * Born-gated on the resolved `modules.illness` 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/illness/*` route also enforces the gate server-side,
+ * so this is a UX redirect, not the security boundary.
+ */
+export default function IllnessPage() {
+ const { user, isLoading, isAuthenticated } = useAuth();
+ const router = useRouter();
+
+ // Born-gated: enabled ONLY when the resolved map says `true`.
+ const enabled = user?.modules?.illness === 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/recovery/page.tsx b/src/app/insights/recovery/page.tsx
index df6a268b7..7db6abea0 100644
--- a/src/app/insights/recovery/page.tsx
+++ b/src/app/insights/recovery/page.tsx
@@ -14,11 +14,16 @@ import { RecoverySection } from "@/components/insights/recovery/recovery-section
* that were stored end-to-end but had no page: day strain, workout strain, ANS
* charge, cardio load, and the day's whole-cycle average / max heart rate and
* kilojoule energy expenditure. The composite RECOVERY_SCORE keeps its anatomy
- * detail at `/insights/scores/recovery`; this page cross-links to it and
- * surrounds it with the raw signals it never surfaced.
+ * detail at `/insights/scores/recovery`.
+ *
+ * v1.18.1 (B1–B3) — the page now follows the canonical per-metric structure:
+ * each present signal renders a block carrying a short explainer, the
+ * max / median / mean stat strip, the same chart with the 7 / 30 / 90 / All
+ * toggle, and a per-chart assessment. The redundant top "Recovery score"
+ * cross-link is removed (reached from the overview already).
*
* Every block is data-gated inside ``, so an account without a
- * strap / ring sees only the calm empty note. Server-authoritative: the tiles
+ * strap / ring sees only the calm empty note. Server-authoritative: the charts
* render stored values, never a recompute.
*/
export default function InsightsRecoveryPage() {
diff --git a/src/app/insights/sleep/page.tsx b/src/app/insights/sleep/page.tsx
index cbb36337d..d6f4271de 100644
--- a/src/app/insights/sleep/page.tsx
+++ b/src/app/insights/sleep/page.tsx
@@ -95,6 +95,26 @@ export default function InsightsSchlafPage() {
>
+ {/*
+ v1.8.7.1 — the duration/architecture assessment lands via the generic
+ metric-status route (`?metric=SLEEP_DURATION`). The shared
+ `` owns the hook + card; only the icon differs from
+ 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.
+ */}
+ }
+ enabled={!isEmpty}
+ />
+
{/*
v1.17.0 — sleep-debt headline + chronotype card. Server-authoritative
(the same DTO iOS renders), gated on the data-bearing branch so a
@@ -108,25 +128,12 @@ export default function InsightsSchlafPage() {
disturbance count + the Oura sleep score) that were ingested but never
rendered. Each tile is data-gated, so the block is invisible for
non-wearable users and collapses metric-by-metric. Server-authoritative
- — the tiles render stored values.
+ — the tiles render stored values. v1.18.1 — the block carries its own
+ `` assessment internally.
*/}
-
- {/*
- v1.8.7.1 — the per-section sleep assessment lands via the generic
- metric-status route (`?metric=SLEEP_DURATION`), closing the slot
- the v1.4.28 deferral left open. The shared ``
- owns the hook + card; only the icon differs from the HealthKit
- scaffold (Moon vs Sparkles). The card mounts only on this
- data-bearing branch, so a source-less account never fetches.
- */}
- }
- enabled={!isEmpty}
- />
);
}
diff --git a/src/app/insights/steps/page.tsx b/src/app/insights/steps/page.tsx
index 9458655b1..0a4a7b401 100644
--- a/src/app/insights/steps/page.tsx
+++ b/src/app/insights/steps/page.tsx
@@ -24,6 +24,7 @@ export default function InsightsStepsPage() {
insightMetric="ACTIVITY_STEPS"
chartKey="steps"
i18nPrefix="insights.steps"
+ explainerMetric="steps"
statIcon={Footprints}
color="#50fa7b"
unit="steps"
diff --git a/src/app/labs/[biomarkerId]/page.tsx b/src/app/labs/[biomarkerId]/page.tsx
new file mode 100644
index 000000000..29189ca39
--- /dev/null
+++ b/src/app/labs/[biomarkerId]/page.tsx
@@ -0,0 +1,51 @@
+"use client";
+
+/**
+ * v1.18.1 — per-biomarker detail page (`/labs/[biomarkerId]`).
+ *
+ * Owns auth + the back link and hands the marker id to
+ * ``, which carries the heading, the dashboard-style
+ * chart with the reference band, and the editable reading history.
+ */
+import { use, useEffect } from "react";
+import { useRouter } from "next/navigation";
+import { Loader2 } from "lucide-react";
+
+import { LabBiomarkerDetail } from "@/components/labs/lab-biomarker-detail";
+import { BackLink } from "@/components/ui/back-link";
+import { useAuth } from "@/hooks/use-auth";
+import { useMounted } from "@/hooks/use-mounted";
+import { useTranslations } from "@/lib/i18n/context";
+
+export default function LabBiomarkerDetailPage({
+ params,
+}: {
+ params: Promise<{ biomarkerId: string }>;
+}) {
+ const { biomarkerId } = use(params);
+ const { isAuthenticated, isLoading } = useAuth();
+ const mounted = useMounted();
+ const router = useRouter();
+ const { t } = useTranslations();
+
+ useEffect(() => {
+ if (!isLoading && !isAuthenticated) {
+ router.push("/auth/login");
+ }
+ }, [isLoading, isAuthenticated, router]);
+
+ if (!mounted || isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ );
+}
diff --git a/src/app/labs/page.tsx b/src/app/labs/page.tsx
index 2030d0510..8ef9835e3 100644
--- a/src/app/labs/page.tsx
+++ b/src/app/labs/page.tsx
@@ -3,11 +3,18 @@
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
-import { Loader2, Plus } from "lucide-react";
+import { FlaskConical, Loader2, MoreVertical, Plus } from "lucide-react";
+import { BiomarkerManager } from "@/components/labs/biomarker-manager";
import { LabForm } from "@/components/labs/lab-form";
import { LabList } from "@/components/labs/lab-list";
import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { useAuth } from "@/hooks/use-auth";
@@ -23,6 +30,10 @@ export default function LabsPage() {
const queryClient = useQueryClient();
const { t } = useTranslations();
const [dialogOpen, setDialogOpen] = useState(false);
+ const [manageOpen, setManageOpen] = useState(false);
+ // Sticky-footer slot element for the add-result sheet (filled by the
+ // ResponsiveSheet `footer` ref so the form portals its action row there).
+ const [addFooterEl, setAddFooterEl] = useState(null);
const refreshVisible = useCallback(
() => queryClient.invalidateQueries({ type: "active" }),
@@ -30,7 +41,7 @@ export default function LabsPage() {
);
const pull = usePullToRefresh({
onRefresh: refreshVisible,
- disabled: dialogOpen,
+ disabled: dialogOpen || manageOpen,
});
useEffect(() => {
@@ -59,13 +70,36 @@ export default function LabsPage() {
{t("labs.subtitle")}
- setDialogOpen(true)}
- className="min-h-11 shrink-0 sm:min-h-9"
- >
-
- {t("labs.addResult")}
-
+
+
setDialogOpen(true)}
+ className="min-h-11 sm:min-h-9"
+ >
+
+ {t("labs.addResult")}
+
+ {/* Secondary actions fold into one overflow kebab beside the primary
+ Add, mirroring the medication-card header (one cluster, not a
+ two-button row). */}
+
+
+
+
+
+
+
+ setManageOpen(true)}>
+
+ {t("labs.biomarker.manage")}
+
+
+
+
}
>
{
setDialogOpen(false);
queryClient.invalidateQueries({
@@ -85,6 +121,15 @@ export default function LabsPage() {
/>
+
+
+
+
setDialogOpen(true)} />
);
diff --git a/src/app/measurements/page.tsx b/src/app/measurements/page.tsx
index c1dfcfacd..1689ed852 100644
--- a/src/app/measurements/page.tsx
+++ b/src/app/measurements/page.tsx
@@ -120,7 +120,10 @@ export default function MeasurementsPage() {
{t("measurements.subtitle")}
- setDialogOpen(true)}>
+ setDialogOpen(true)}
+ >
{t("measurements.addMeasurement")}
diff --git a/src/app/settings/[section]/page.tsx b/src/app/settings/[section]/page.tsx
index 151e90266..78115b334 100644
--- a/src/app/settings/[section]/page.tsx
+++ b/src/app/settings/[section]/page.tsx
@@ -18,9 +18,10 @@ import { ModulesSection } from "@/components/settings/modules-section";
import { MoodSection } from "@/components/settings/mood-section";
import { NotificationsSection } from "@/components/settings/notifications-section";
import { SectionPlaceholder } from "@/components/settings/section-placeholder";
-// v1.18.0 (S3) — `sources` no longer has a standalone route; source priority
-// renders inside Settings → Integrations as the "Sources" sub-tab.
-// `/settings/sources` 301-redirects to `/settings/integrations` (next.config.ts).
+// v1.18.1 (D4) — `channels` and `sources` are standalone left-side entries
+// again (split out of the Integrations sub-tabs).
+import { ChannelsSection } from "@/components/settings/channels-section";
+import { SourcesSection } from "@/components/settings/sources-section";
import { ThresholdsSection } from "@/components/settings/thresholds-section";
import {
SETTINGS_SECTION_SLUGS,
@@ -55,6 +56,8 @@ const SECTION_COMPONENTS: Record<
ai: AiSection,
coach: CoachSection,
integrations: IntegrationsSection,
+ channels: ChannelsSection,
+ sources: SourcesSection,
notifications: NotificationsSection,
layout: LayoutSection,
dashboard: DashboardSection,
diff --git a/src/app/vorsorge/page.tsx b/src/app/vorsorge/page.tsx
index e123b9828..126f17539 100644
--- a/src/app/vorsorge/page.tsx
+++ b/src/app/vorsorge/page.tsx
@@ -1,19 +1,38 @@
"use client";
+import { useCallback } from "react";
import { Loader2 } from "lucide-react";
+import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
+import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
+import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
import { VorsorgeSection } from "@/components/measurement-reminders/vorsorge-section";
+import { queryKeys } from "@/lib/query-keys";
/**
* v1.17.1 — Vorsorge (preventive-care) reminders page. The dedicated
* feature surface for "wann muss ich was wo machen". Auth-gated; the
* section component owns the list + create flow.
+ *
+ * v1.18.1 — pull-to-refresh parity with every peer page (labs, mood,
+ * measurements): a pull invalidates the measurement-reminders read so the
+ * list + server-computed next-due repaint.
*/
export default function VorsorgePage() {
const { isAuthenticated, isLoading } = useAuth();
const mounted = useMounted();
+ const queryClient = useQueryClient();
+
+ const refresh = useCallback(
+ () =>
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.measurementReminders(),
+ }),
+ [queryClient],
+ );
+ const pull = usePullToRefresh({ onRefresh: refresh });
if (!mounted || isLoading) {
return (
@@ -25,6 +44,7 @@ export default function VorsorgePage() {
return (
);
diff --git a/src/components/admin/__tests__/sections.test.tsx b/src/components/admin/__tests__/sections.test.tsx
index 4127ed94c..c5b9ba1da 100644
--- a/src/components/admin/__tests__/sections.test.tsx
+++ b/src/components/admin/__tests__/sections.test.tsx
@@ -155,7 +155,10 @@ describe("admin sections — SSR smoke", () => {
expect(html).toContain("No feedback yet");
});
- it("admin slug list contains ai-quality (renderer parity)", () => {
- expect(ADMIN_SECTION_SLUGS).toContain("ai-quality");
+ it("admin slug list contains coach (renderer parity)", () => {
+ // v1.18.1 — ai-quality / assistant / coach-feedback folded into one
+ // "coach" section. The standalone component still renders above; the
+ // slug parity assertion tracks the consolidated nav entry.
+ expect(ADMIN_SECTION_SLUGS).toContain("coach");
});
});
diff --git a/src/components/admin/admin-shell.tsx b/src/components/admin/admin-shell.tsx
index c8ce68f90..70924d726 100644
--- a/src/components/admin/admin-shell.tsx
+++ b/src/components/admin/admin-shell.tsx
@@ -23,14 +23,12 @@ import {
Info,
KeyRound,
Inbox,
- MessageSquare,
Plug,
Radio,
ScrollText,
Server,
Settings,
ShieldAlert,
- SlidersHorizontal,
Sparkles,
Ticket,
Users,
@@ -76,21 +74,14 @@ export const ADMIN_SECTIONS: readonly AdminSection[] = [
titleKey: "admin.section.integrations.title",
icon: Plug,
},
+ // v1.18.1 — single "Coach" entry replacing the former ai-quality,
+ // assistant, and coach-feedback trio. The section body stacks all
+ // three surfaces so the coach-quality picture lives in one place.
{
- slug: "ai-quality",
- titleKey: "admin.section.ai-quality.title",
+ slug: "coach",
+ titleKey: "admin.section.coach.title",
icon: Sparkles,
},
- {
- slug: "assistant",
- titleKey: "admin.section.assistant.title",
- icon: SlidersHorizontal,
- },
- {
- slug: "coach-feedback",
- titleKey: "admin.section.coach-feedback.title",
- icon: MessageSquare,
- },
{
slug: "feedback",
titleKey: "admin.section.feedback.title",
diff --git a/src/components/admin/ai-quality-section.tsx b/src/components/admin/ai-quality-section.tsx
index 4a026e59f..ddcd6473f 100644
--- a/src/components/admin/ai-quality-section.tsx
+++ b/src/components/admin/ai-quality-section.tsx
@@ -65,64 +65,56 @@ export function AiQualitySection() {
},
});
- if (query.isLoading) {
- return (
-
-
-
- {t("admin.aiQuality.loading")}
-
-
- );
- }
-
- if (query.isError) {
- return (
-
- {t("admin.aiQuality.loadError")}
-
- );
- }
-
- const summary = query.data;
-
- if (!summary || summary.buckets.length === 0) {
- return (
-
-
-
- {t("admin.aiQuality.empty")}
-
-
- );
- }
+ const summary = query.data ?? null;
+ const buckets = summary?.buckets ?? [];
+ const hasData = !!summary && buckets.length > 0;
+ // v1.18.1 — keep the outer card + header rendered across every query
+ // state (loading / error / empty / data) so the heading baseline stays at
+ // a constant Y-offset and the section never snaps downward when the data
+ // resolves. Mirrors (header outside the
+ // fetch-state branch).
return (
- {t("admin.aiQuality.windowLabel", {
- days: String(summary.windowDays),
- })}
- {" · "}
- {t("admin.aiQuality.generatedAtLabel")}{" "}
- {fmt.dateTime(summary.generatedAt)}
-
+ hasData && summary ? (
+
+ {t("admin.aiQuality.windowLabel", {
+ days: String(summary.windowDays),
+ })}
+ {" · "}
+ {t("admin.aiQuality.generatedAtLabel")}{" "}
+ {fmt.dateTime(summary.generatedAt)}
+
+ ) : null
}
/>
-
-
-
+ {query.isLoading ? (
+
+
+
+ {t("admin.aiQuality.loading")}
+
+
+ ) : query.isError ? (
+
+ {t("admin.aiQuality.loadError")}
+
+ ) : !hasData ? (
+
+ {t("admin.aiQuality.empty")}
+
+ ) : (
+
+
+
{t("admin.aiQuality.colSeverity")}
@@ -148,7 +140,7 @@ export function AiQualitySection() {
- {summary.buckets.map((bucket, idx) => {
+ {buckets.map((bucket, idx) => {
const ratePct = Math.round(bucket.helpfulRate * 100);
return (
-
+
+ )}
);
}
diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx
index 138146e38..1ec8bbb11 100644
--- a/src/components/admin/backups-section.tsx
+++ b/src/components/admin/backups-section.tsx
@@ -39,7 +39,6 @@ import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
-import { formatDateTime } from "@/lib/format";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -70,6 +69,7 @@ function RestoreRowDialog({
onConfirm: () => void;
}) {
const { t } = useTranslations();
+ const fmt = useFormatters();
const [open, setOpen] = useState(false);
const [typed, setTyped] = useState("");
const matched = typed.trim() === "RESTORE";
@@ -108,7 +108,7 @@ function RestoreRowDialog({
{t("admin.section.backups.restoreDescription", {
username: row.username,
- when: formatDateTime(row.createdAt),
+ when: fmt.dateTime(row.createdAt),
})}
@@ -333,6 +333,13 @@ export function BackupsSection() {
return (
+ {/* v1.18.1 E3 — the snapshot count leads (numbers first), the
+ "Run now" button follows, and the explainer + docs link move
+ into the header's description slot. Previously the description
+ sat in a separate `mt-1 pl-7` paragraph that crowded the button
+ directly above it; routing it through the header's `space-y-1`
+ stack restores breathing room and left-aligns it under the
+ title. */}
}
+ description={
+
+ {t("admin.section.backups.description")}{" "}
+ {/* External docs link — `noopener noreferrer` because this
+ leaves the admin shell. */}
+
+
+ {t("admin.section.backups.docsLink")}
+
+
+ }
/>
-
- {t("admin.section.backups.description")}{" "}
- {/* External docs link — Phase E will publish the matching page on
- the docs site. `noopener noreferrer` because this leaves the
- admin shell. */}
-
-
- {t("admin.section.backups.docsLink")}
-
-
{/* Upload card — separate from the table so admins can ingest a
backup file independently of any existing rows. The visible
@@ -493,7 +501,7 @@ export function BackupsSection() {
{formatBytes(row.sizeBytes, fmt)}
- {formatDateTime(row.createdAt)}
+ {fmt.dateTime(row.createdAt)}
diff --git a/src/components/admin/danger-zone-section.tsx b/src/components/admin/danger-zone-section.tsx
index 93cebfdd3..4135d3a4a 100644
--- a/src/components/admin/danger-zone-section.tsx
+++ b/src/components/admin/danger-zone-section.tsx
@@ -15,16 +15,36 @@ import {
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { apiDelete } from "@/lib/api/api-fetch";
+/**
+ * The literal an admin must type to arm the wipe. Mirrors the Backups
+ * `RESTORE` gate — and matches the server's typed-token defence at
+ * `src/app/api/admin/data/route.ts` (which 422s any other `confirm`).
+ */
+const WIPE_CONFIRM_TOKEN = "DELETE ALL";
+
export function DangerZoneSection() {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [wipeMsg, setWipeMsg] = useState
(null);
+ // Typed-confirmation gate. The destructive button stays disabled until
+ // the admin types `DELETE ALL` verbatim, matching the Backups Restore
+ // dialog (which forces typing `RESTORE`) so the FAR more destructive
+ // global wipe is not the weaker gate. Reset whenever the dialog closes.
+ const [open, setOpen] = useState(false);
+ const [typed, setTyped] = useState("");
+ const matched = typed.trim() === WIPE_CONFIRM_TOKEN;
const wipeAllData = useMutation({
mutationFn: async () => {
+ // Idempotency-Key prevents a double-submit from re-running the
+ // destructive transaction, matching the Backups restore path.
+ const idempotencyKey = `admin-data-wipe-${crypto.randomUUID()}`;
return apiDelete<{
measurements: number;
intakeEvents: number;
@@ -35,7 +55,11 @@ export function DangerZoneSection() {
notificationChannels: number;
pushSubscriptions: number;
telegramScheduledDeletions: number;
- }>("/api/admin/data", { confirm: "DELETE ALL" });
+ }>(
+ "/api/admin/data",
+ { confirm: WIPE_CONFIRM_TOKEN },
+ { headers: { "Idempotency-Key": idempotencyKey } },
+ );
},
onSuccess: (data) => {
queryClient.invalidateQueries();
@@ -59,24 +83,31 @@ export function DangerZoneSection() {
});
return (
+ // v1.18.1 E4 — adopt the shared card shape (rounded-xl border + the
+ // `SettingsCardHeader` icon/title/description/status layout) so the
+ // Danger Zone reads like every other admin section. The destructive
+ // tint + AlertTriangle icon keep it visually flagged as dangerous;
+ // the delete affordance now sits in the header status slot, properly
+ // aligned beside its label rather than floating below a lone icon.
- {/* v1.4.19 A8 / F-08: page header `admin.section.danger-zone.*`
- already names the section, so the card-level title was a
- duplicate of the page title. The destructive icon stays so
- the card still flags itself as dangerous at a glance. */}
-
-
-
{t("admin.deleteAllData")}
-
- {t("admin.deleteAllDescription")}
-
-
-
+ {
+ setOpen(next);
+ if (!next) setTyped("");
+ }}
+ >
{wipeAllData.isPending ? (
@@ -95,13 +126,36 @@ export function DangerZoneSection() {
{t("admin.deleteAllConfirmDescription")}
+
+
+ {t("admin.deleteAllPromptLabel", {
+ token: WIPE_CONFIRM_TOKEN,
+ })}
+
+ setTyped(e.target.value)}
+ autoComplete="off"
+ spellCheck={false}
+ placeholder={WIPE_CONFIRM_TOKEN}
+ />
+
{t("common.cancel")}
wipeAllData.mutate()}
+ onClick={(e) => {
+ if (!matched) {
+ e.preventDefault();
+ return;
+ }
+ setOpen(false);
+ setTyped("");
+ wipeAllData.mutate();
+ }}
>
{wipeAllData.isPending && (
@@ -111,17 +165,17 @@ export function DangerZoneSection() {
-
- {wipeMsg && (
-
- {wipeMsg}
-
- )}
-
+ }
+ />
+ {wipeMsg && (
+
+ {wipeMsg}
+
+ )}
);
}
diff --git a/src/components/admin/section-slugs.ts b/src/components/admin/section-slugs.ts
index 8581cd072..9fa94f773 100644
--- a/src/components/admin/section-slugs.ts
+++ b/src/components/admin/section-slugs.ts
@@ -14,13 +14,13 @@ export const ADMIN_SECTION_SLUGS = [
"general",
"services",
"integrations",
- "ai-quality",
- // v1.4.31 — operator-side assistant feature-flag toggles. Sits
- // between `ai-quality` (provider config) and `coach-feedback`
- // (in-app feedback inbox) per
- // `.planning/research/v15-assistant-optional.md` Part B.
- "assistant",
- "coach-feedback",
+ // v1.18.1 — the former `ai-quality`, `assistant`, and `coach-feedback`
+ // sections collapse into one "Coach" area. It holds the assistant
+ // surface toggles, the server-key + module-availability operator
+ // config, and both feedback-quality tables (Coach + Insights). Three
+ // navigation entries for one coherent coach-quality concern read as
+ // clutter; one card opens the full picture.
+ "coach",
"feedback",
"reminders",
"users",
diff --git a/src/components/cycle/__tests__/field-info.test.tsx b/src/components/cycle/__tests__/field-info.test.tsx
new file mode 100644
index 000000000..c97a58e63
--- /dev/null
+++ b/src/components/cycle/__tests__/field-info.test.tsx
@@ -0,0 +1,36 @@
+import { describe, it, expect } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { FieldInfo } from "../field-info";
+import { I18nProvider } from "@/lib/i18n/context";
+
+function render(node: React.ReactNode) {
+ return renderToStaticMarkup(
+ {node} ,
+ );
+}
+
+describe("", () => {
+ it("is a real focusable button carrying the accessible label", () => {
+ const html = render(
+ ,
+ );
+ // A real — keyboard- and SR-reachable, not hover-only.
+ expect(html).toContain('type="button"');
+ expect(html).toContain('aria-label="What is basal body temperature?"');
+ // The e2e hook the cycle sheet asserts on.
+ expect(html).toContain('data-slot="cycle-field-info"');
+ });
+
+ it("starts closed so the explainer opens on tap/focus, not hover-only", () => {
+ // Radix renders the tooltip content lazily (portal, on open); the trigger
+ // therefore reports a closed state until interacted with.
+ const html = render(
+ ,
+ );
+ expect(html).toContain('data-state="closed"');
+ });
+});
diff --git a/src/components/cycle/__tests__/phase-day-header.test.tsx b/src/components/cycle/__tests__/phase-day-header.test.tsx
new file mode 100644
index 000000000..6e6f539d3
--- /dev/null
+++ b/src/components/cycle/__tests__/phase-day-header.test.tsx
@@ -0,0 +1,73 @@
+import { describe, it, expect } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { PhaseDayHeader } from "../log-day-sheet";
+import { I18nProvider } from "@/lib/i18n/context";
+
+function render(node: React.ReactNode) {
+ return renderToStaticMarkup(
+ {node} ,
+ );
+}
+
+const okGate = {
+ predictionEnabled: true,
+ rawChartMode: false,
+ cyclesObserved: 4,
+};
+
+describe(" (honesty gate)", () => {
+ it("shows the phase + day when the gate passes", () => {
+ const html = render(
+ ,
+ );
+ expect(html).toContain('data-slot="cycle-log-phase-header"');
+ expect(html).toContain('data-phase="OVULATORY"');
+ // "Day 14 · Ovulatory" — the phase word is only claimed under a passing gate.
+ expect(html).toContain("Day 14");
+ expect(html).toContain("Ovulatory");
+ });
+
+ it("makes no phase claim when prediction is off (neutral day header)", () => {
+ const html = render(
+ ,
+ );
+ expect(html).toContain('data-phase="none"');
+ // Still anchors to the day count, but never names the phase.
+ expect(html).toContain("Day 14");
+ expect(html).not.toContain("Ovulatory");
+ });
+
+ it("makes no phase claim with too few observed cycles", () => {
+ const html = render(
+ ,
+ );
+ expect(html).toContain('data-phase="none"');
+ expect(html).not.toContain("Luteal");
+ });
+
+ it("renders nothing when neither a trustworthy phase nor a day count exists", () => {
+ const html = render(
+ ,
+ );
+ expect(html).toBe("");
+ });
+});
diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx
index 9f3d155e3..35e831be1 100644
--- a/src/components/cycle/cycle-view.tsx
+++ b/src/components/cycle/cycle-view.tsx
@@ -403,6 +403,12 @@ export function CycleView() {
date={selectedDate}
today={today}
activePeriod={wheel.phase === "MENSTRUAL"}
+ phase={wheel.phase}
+ dayOfCycle={wheel.dayOfCycle}
+ goal={goal}
+ predictionEnabled={calProfile?.predictionEnabled ?? false}
+ rawChartMode={calProfile?.rawChartMode ?? false}
+ cyclesObserved={calProfile?.cyclesObserved ?? 0}
/>
);
diff --git a/src/components/cycle/field-info.tsx b/src/components/cycle/field-info.tsx
new file mode 100644
index 000000000..04a0aded7
--- /dev/null
+++ b/src/components/cycle/field-info.tsx
@@ -0,0 +1,61 @@
+"use client";
+
+import { Info } from "lucide-react";
+
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { cn } from "@/lib/utils";
+
+/**
+ * v1.18.1 — the cycle log-sheet field-explainer affordance.
+ *
+ * A small "?" / info icon that puts a factual, descriptive-only one-liner
+ * (what a clinical field means — BBT, cervical mucus, an LH surge) right at
+ * the point of capture, so a first-time user never has to leave the sheet to
+ * understand a term. Mirrors the mood surface's `MoodExplainerIcon` precedent
+ * (self-contained `TooltipProvider`, a real focusable `` trigger with
+ * an `aria-label`, keyboard- and SR-reachable via Radix) but bumps the hit
+ * area to ≥44 px for touch, since the cycle sheet is a thumb-first surface.
+ *
+ * Copy stays descriptive-only — never diagnosis or medical advice — matching
+ * the phase-education honesty gate.
+ */
+export function FieldInfo({
+ label,
+ detail,
+ className,
+}: {
+ /** Accessible name for the trigger (what the icon explains). */
+ label: string;
+ /** The explanation shown in the tooltip. */
+ detail: string;
+ className?: string;
+}) {
+ return (
+
+
+
+
+
+
+ {detail}
+
+
+
+ );
+}
diff --git a/src/components/cycle/log-day-sheet.tsx b/src/components/cycle/log-day-sheet.tsx
index 20b9558e4..79c76333a 100644
--- a/src/components/cycle/log-day-sheet.tsx
+++ b/src/components/cycle/log-day-sheet.tsx
@@ -48,8 +48,10 @@ import {
} from "@/components/ui/sheet-section";
import { useTranslations } from "@/lib/i18n/context";
import { CUSTOM_SYMPTOM_ICON_ALLOWLIST } from "@/lib/cycle/custom-symptoms-shared";
+import { FieldInfo } from "./field-info";
import { CYCLE_SYMPTOM_CATALOG } from "./symptom-catalog";
-import { FLOW_HUE } from "./phase-tokens";
+import { FLOW_HUE, PHASE_HUE } from "./phase-tokens";
+import type { CyclePhase, CycleGoal } from "./types";
import {
useCreateCustomSymptom,
useCustomSymptoms,
@@ -309,6 +311,11 @@ export function noteCount(s: DayLogFormState): number {
return s.note.trim() ? 1 : 0;
}
+/** The MIN_CYCLES gate the phase-education card uses — mirrored here so the
+ * sheet's phase-context header makes the same honesty claim (no phase label
+ * until prediction is on, not raw-chart, and at least three cycles seen). */
+const MIN_CYCLES_FOR_PHASE = 3;
+
export interface LogDaySheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -322,6 +329,86 @@ export interface LogDaySheetProps {
* progress (QA M2).
*/
activePeriod?: boolean;
+ /**
+ * The active cycle phase from the calendar read (or null). Drives the
+ * hue-tinted phase-context header so the sheet visually belongs to the ring.
+ */
+ phase?: CyclePhase | null;
+ /** Day-of-cycle for the header ("Day 14 · Ovulatory"); null when no cycle. */
+ dayOfCycle?: number | null;
+ /**
+ * The user's cycle goal — fertility goals (TTC / avoid-pregnancy) auto-open
+ * the symptothermal (Temperature & ovulation) section, mirroring Apple's
+ * "show the categories that matter to you".
+ */
+ goal?: CycleGoal;
+ /** Honesty-gate inputs for the phase-context header (mirror the education card). */
+ predictionEnabled?: boolean;
+ rawChartMode?: boolean;
+ cyclesObserved?: number;
+}
+
+/**
+ * v1.18.1 — the hue-tinted phase-context strip atop the sheet, echoing the
+ * ring ("Day 14 · Ovulatory" + a phase hue dot). It anchors the capture
+ * surface to the wheel + education card so they read as one family. Honesty
+ * gate: when the phase label isn't trustworthy (prediction off / raw-chart /
+ * fewer than three cycles, or no resolved phase) it shows a neutral header
+ * with no phase claim — never a confident phase word the data can't back.
+ */
+export function PhaseDayHeader({
+ phase,
+ dayOfCycle,
+ predictionEnabled,
+ rawChartMode,
+ cyclesObserved,
+}: {
+ phase: CyclePhase | null;
+ dayOfCycle: number | null;
+ predictionEnabled: boolean;
+ rawChartMode: boolean;
+ cyclesObserved: number;
+}) {
+ const { t } = useTranslations();
+ const stillLearning =
+ !phase ||
+ !predictionEnabled ||
+ rawChartMode ||
+ cyclesObserved < MIN_CYCLES_FOR_PHASE;
+
+ // Nothing trustworthy to anchor to (no phase + no day count) → render no
+ // strip at all rather than an empty tile.
+ if (stillLearning && dayOfCycle == null) return null;
+
+ const hue = phase ? PHASE_HUE[phase] : PHASE_HUE.LUTEAL;
+ const showPhase = !stillLearning && phase != null;
+
+ return (
+
+ {showPhase ? (
+
+ ) : null}
+
+ {showPhase && dayOfCycle != null
+ ? t("cycle.sheet.phaseDayHeader", {
+ day: dayOfCycle,
+ phase: t(`cycle.phase.${phase}`),
+ })
+ : dayOfCycle != null
+ ? t("cycle.ring.dayOfCycle", { day: dayOfCycle })
+ : t("cycle.phaseEducation.stillLearning")}
+
+
+ );
}
function Chip({
@@ -383,6 +470,12 @@ export function LogDaySheet({
onOpenChange,
date,
activePeriod = false,
+ phase = null,
+ dayOfCycle = null,
+ goal,
+ predictionEnabled = false,
+ rawChartMode = false,
+ cyclesObserved = 0,
}: LogDaySheetProps) {
const { t } = useTranslations();
const logDay = useLogDay();
@@ -429,6 +522,12 @@ export function LogDaySheet({
);
const [note, setNote] = useState("");
const [rowId, setRowId] = useState(null);
+ // Goal-aware disclosure for the symptothermal (Temperature & ovulation)
+ // section. Re-evaluated once per (open, date, fetched-row) in the hydration
+ // block below: open it when the goal is fertility-oriented (Apple's
+ // "show the categories that matter to you") OR when an edited day already
+ // holds one of its signs (so a pre-filled value is never hidden — §3.4).
+ const [fertilityOpen, setFertilityOpen] = useState(false);
// Hydrate the form from the fetched DTO once per (open, date, fetched-row)
// tuple. Tracked as an "adjust state when a prop changes" during render
@@ -459,6 +558,22 @@ export function LogDaySheet({
setProgesteroneTest(dto?.progesteroneTest ?? null);
setContraceptive(dto?.contraceptive ?? null);
setNote(dto?.note ?? "");
+ // Open the symptothermal section when the goal makes it primary, or when
+ // the edited day already carries any of its signs (BBT / OPK / mucus /
+ // cervix) — so a pre-filled fertility value is never hidden on edit.
+ const fertilityFilled =
+ dto?.basalBodyTempC != null ||
+ dto?.ovulationTest != null ||
+ (showCervix
+ ? dto?.cervixPosition != null ||
+ dto?.cervixFirmness != null ||
+ dto?.cervixOpening != null
+ : dto?.cervicalMucus != null);
+ setFertilityOpen(
+ goal === "TRYING_TO_CONCEIVE" ||
+ goal === "AVOID_PREGNANCY" ||
+ fertilityFilled,
+ );
} else if (!open && lastFormKey !== null) {
setLastFormKey(null);
}
@@ -569,6 +684,7 @@ export function LogDaySheet({
onOpenChange={onOpenChange}
title={t("cycle.sheet.title")}
description={t("cycle.sheet.description")}
+ contentWidth="lg"
footer={
<>
{rowId ? (
@@ -603,6 +719,17 @@ export function LogDaySheet({
>
}
>
+ {/* Phase-context strip — echoes the ring (day-of-cycle + phase + hue)
+ so the capture surface belongs to the wheel. Honesty-gated: no phase
+ claim until prediction is trustworthy. */}
+
+
{/* One-tap period boundaries — available on any selected date so a
forgotten day-1 can be corrected retroactively (M3). "End period"
shows only while a period is actually open (M2). */}
@@ -651,10 +778,19 @@ export function LogDaySheet({
))}
-
-
- {t("cycle.sheet.intermenstrualBleeding")}
-
+
+
+
+ {t("cycle.sheet.intermenstrualBleeding")}
+
+
+
{/* Temperature & ovulation — BBT, OPK, and the symptothermal secondary
- sign (mucus or cervix) grouped under one disclosure. */}
+ sign (mucus or cervix) grouped under one disclosure. Goal-aware
+ default: open for fertility goals or when an edited day already
+ carries a fertility sign. */}
}
>
{/* BBT */}
-
-
+ }
+ >
+ setBbt(e.target.value)}
placeholder={t("cycle.sheet.temperaturePlaceholder")}
- className="border-input bg-background focus-visible:ring-ring/50 w-32 rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none"
+ className="w-32"
aria-label={t("cycle.sheet.temperature")}
/>
+
+ {t("cycle.sheet.bbtHint")}
+
{resolveBbt(bbt) != null && (
@@ -768,7 +919,15 @@ export function LogDaySheet({
{/* Ovulation test */}
-
+
+ }
+ >
{OPK_VALUES.map((v) => (
) : (
-
+
+ }
+ >
{MUCUS_VALUES.map((v) => (
-
-
- {t("cycle.sheet.intercourse")}
-
+
+
+
+ {t("cycle.sheet.intercourse")}
+
+
+
{intercourse ? (
-
-
- {t("cycle.sheet.protected")}
-
+
+
+
+ {t("cycle.sheet.protected")}
+
+
+
}
>
{/* Pregnancy test */}
-
+
+ }
+ >
{TEST_RESULTS.map((v) => (
{/* Progesterone test */}
-
+
+ }
+ >
{TEST_RESULTS.map((v) => (
- {label}
+
{children}
);
diff --git a/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx b/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx
index 30ee06eb4..451b8af0a 100644
--- a/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx
+++ b/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx
@@ -361,7 +361,7 @@ describe(" — verdict variants", () => {
});
describe(" — dose row", () => {
- it("prints the day tally with the v1.16.6 card chrome and the Pill icon", () => {
+ it("prints the day tally with the neutral chip chrome and the Pill icon", () => {
const html = render(
baseSnapshot({
medsToday: medsToday({ scheduledToday: 3, takenToday: 1 }),
@@ -372,15 +372,13 @@ describe(" — dose row", () => {
/]*data-slot="dashboard-hero-doses"[^>]*>/,
);
expect(doseRow).not.toBeNull();
- for (const cls of [
- "bg-card/65",
- "border-border/60",
- "rounded-xl",
- "shadow-sm",
- "backdrop-blur-sm",
- ]) {
+ // v1.18.1: chip sits on a neutral muted surface over the plain card —
+ // no translucent card tint, no blur, no shadow (the gradient is gone).
+ for (const cls of ["bg-muted/50", "border-border/60", "rounded-xl"]) {
expect(doseRow![0]).toContain(cls);
}
+ expect(doseRow![0]).not.toContain("backdrop-blur");
+ expect(doseRow![0]).not.toContain("shadow-sm");
});
it("all doses resolved (taken + skipped) → 'Alle Dosen für heute erledigt'", () => {
@@ -581,8 +579,17 @@ describe("
— source pins", () => {
expect(src).toMatch(/resolveDashboardVerdict\(snapshot,\s*new Date\(\)\)/);
});
- it("uses the hero-gradient chrome WITHOUT glow-purple", () => {
- expect(src).toContain("hero-gradient");
+ it("wears the plain card surface — no gradient, no glow", () => {
+ // v1.18.1: the band drops the gradient hero treatment and sits on the
+ // same `bg-card` + border + radius as the surrounding chart cards.
+ expect(src).toContain("bg-card");
+ expect(src).toContain("border-border");
+ expect(src).toContain("rounded-xl");
+ expect(src).not.toContain("hero-gradient");
expect(src).not.toContain("glow-purple");
});
+
+ it("renders the score ring flat (no bloom / pulse / sheen / sweep)", () => {
+ expect(src).toMatch(/]*\n(?:[^>]*\n)*?\s*flat\b/);
+ });
});
diff --git a/src/components/dashboard/hero/dashboard-hero.tsx b/src/components/dashboard/hero/dashboard-hero.tsx
index 6daeb2566..02154f6de 100644
--- a/src/components/dashboard/hero/dashboard-hero.tsx
+++ b/src/components/dashboard/hero/dashboard-hero.tsx
@@ -15,8 +15,10 @@
* band's anchor, larger and heavier than the verdict so the
* personalised line is not drowned by the content below it), the
* verdict sentence with its single CTA button, and the dose row;
- * - right column: the shared `` at `sm` geometry. A null
- * score renders the ring's provisional state at the identical
+ * - right column: the shared `` at `sm` geometry, in its
+ * `flat` treatment (no bloom / pulse / sheen / sweep) so the dial
+ * sits as calmly as the surrounding chart cards. A null score
+ * renders the ring's provisional state at the identical
* 120 px footprint, so the column never collapses. The provisional
* label is honest about WHY the score is null: when the snapshot
* already carries score inputs (weight / BP summaries, mood
@@ -45,6 +47,7 @@ import Link from "next/link";
import { Pill } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScoreRing } from "@/components/insights/derived/score-ring";
+import { RestModeBanner } from "@/components/insights/rest-mode-banner";
import {
resolveDashboardVerdict,
type DashboardVerdictVariant,
@@ -220,14 +223,16 @@ export function DashboardHero({
`). No
+ // gradient, no glow — it reads as one of the dashboard tiles.
+ "bg-card border-border relative isolate overflow-hidden rounded-xl border",
+ "min-h-[8.75rem] p-4 md:min-h-[9.5rem] md:p-6",
)}
>
-
+
+
{/* Greeting leads the typographic hierarchy: larger + heavier
than the verdict so the personalised line reads first.
@@ -272,7 +277,7 @@ export function DashboardHero({
+ {/* v1.18.1 — Rest Mode cue beneath the verdict/score row. Self-gating
+ (renders nothing unless an episode is active), value-free, and
+ untinted so it frames the score without alarming. Closes the
+ web↔iOS parity gap: iOS already mirrors `score.restMode`. */}
+
+
);
}
diff --git a/src/components/illness/episode-menu.tsx b/src/components/illness/episode-menu.tsx
new file mode 100644
index 000000000..6fe24c37e
--- /dev/null
+++ b/src/components/illness/episode-menu.tsx
@@ -0,0 +1,129 @@
+"use client";
+
+/**
+ * v1.18.1 — shared illness-episode overflow menu (mirrors the medication
+ * card menu): one kebab folding Edit / Mark recovered / Delete. Delete is
+ * gated behind an AlertDialog (every destructive peer guards this; an episode
+ * delete cascades its day-logs + encrypted notes, so a single unguarded tap
+ * was the Medium finding). Neutral throughout.
+ */
+import { useState } from "react";
+import { MoreVertical, Pencil, CheckCircle2, Trash2 } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { useTranslations } from "@/lib/i18n/context";
+
+import { useDeleteEpisode } from "./use-illness";
+import type { IllnessEpisodeDTO } from "./types";
+
+export interface EpisodeMenuProps {
+ episode: IllnessEpisodeDTO;
+ onEdit: () => void;
+ /** Provided only when the episode is active + non-chronic. */
+ onResolve?: () => void;
+ resolving?: boolean;
+ /** Called after a confirmed delete (e.g. navigate back from a detail page). */
+ onDeleted?: () => void;
+}
+
+export function EpisodeMenu({
+ episode,
+ onEdit,
+ onResolve,
+ resolving,
+ onDeleted,
+}: EpisodeMenuProps) {
+ const { t } = useTranslations();
+ const del = useDeleteEpisode();
+ const [confirmOpen, setConfirmOpen] = useState(false);
+
+ async function handleConfirmDelete() {
+ try {
+ await del.mutateAsync(episode.id);
+ setConfirmOpen(false);
+ onDeleted?.();
+ } catch {
+ // Keep the dialog open; the list will surface any error on next read.
+ }
+ }
+
+ return (
+ <>
+
+
+ e.stopPropagation()}
+ >
+
+
+
+
+
+
+ {t("common.edit")}
+
+ {onResolve ? (
+
+
+ {t("illness.markRecovered")}
+
+ ) : null}
+
+ {
+ e.preventDefault();
+ setConfirmOpen(true);
+ }}
+ >
+
+ {t("common.delete")}
+
+
+
+
+
+
+
+
+ {t("illness.deleteConfirm.title")}
+
+
+ {t("illness.deleteConfirm.body", { label: episode.label })}
+
+
+
+ {t("common.cancel")}
+
+ {t("common.delete")}
+
+
+
+
+ >
+ );
+}
diff --git a/src/components/illness/illness-correlation-card.tsx b/src/components/illness/illness-correlation-card.tsx
new file mode 100644
index 000000000..389530700
--- /dev/null
+++ b/src/components/illness/illness-correlation-card.tsx
@@ -0,0 +1,151 @@
+"use client";
+
+/**
+ * v1.18.1 P3 — the per-episode retrospective correlation card ("How it
+ * unfolded"). Server-authoritative + coverage-gated: it pattern-matches the
+ * `Derived
` `status` and renders "still learning" when the signal is thin,
+ * NEVER a fabricated number. Retrospective only — never a prediction or a
+ * diagnosis. Neutral palette throughout (the med-card no-colour rule
+ * generalised); the red-flag is a calm "worth a closer look" note, framed to
+ * escalate without alarming colour.
+ */
+import { ArrowDown, ArrowUp, Info } from "lucide-react";
+
+import { Card, CardContent, CardHeader } from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
+import { useTranslations } from "@/lib/i18n/context";
+
+import { useIllnessCorrelation } from "./use-illness";
+import type {
+ IllnessCorrelationValue,
+ IllnessVitalDeviation,
+} from "./types";
+
+/** Translate a MeasurementType to its illness-surface label. */
+function useVitalLabel() {
+ const { t } = useTranslations();
+ return (type: string) => t(`illness.vital.${type}`);
+}
+
+function DeviationRow({ d }: { d: IllnessVitalDeviation }) {
+ const { t } = useTranslations();
+ const vitalLabel = useVitalLabel();
+ const Arrow = d.direction === "above" ? ArrowUp : ArrowDown;
+ return (
+
+
+ {vitalLabel(d.type)}
+
+
+
+ {t(`illness.correlation.direction.${d.direction}`)}
+
+
+ );
+}
+
+function CorrelationBody({ value }: { value: IllnessCorrelationValue }) {
+ const { t } = useTranslations();
+ const gap = value.recoveryGapDays;
+
+ return (
+
+ {value.redFlags.length > 0 ? (
+
+
+ {t("illness.correlation.redFlagTitle")}
+
+
+ {value.redFlags.map((f) => (
+
+ {f.reason === "sustained_low_spo2"
+ ? t("illness.correlation.redFlagSpo2")
+ : t("illness.correlation.redFlagFever")}
+
+ ))}
+
+
+ ) : null}
+
+ {gap !== null ? (
+
+
+ {t("illness.correlation.recoveryGapTitle")}
+
+
+ {gap > 0
+ ? t("illness.correlation.recoveryGapLag", { days: gap })
+ : gap < 0
+ ? t("illness.correlation.recoveryGapLead", { days: Math.abs(gap) })
+ : t("illness.correlation.recoveryGapSame")}
+
+
+ ) : null}
+
+ {value.preOnset.length > 0 ? (
+
+
+ {t("illness.correlation.preOnsetTitle")}
+
+
+ {t("illness.correlation.preOnsetBody")}
+
+
+ {value.preOnset.map((d) => (
+
+ ))}
+
+
+ ) : null}
+
+ {value.nadir.length > 0 ? (
+
+
+ {t("illness.correlation.nadirTitle")}
+
+
+ {t("illness.correlation.nadirBody")}
+
+
+ {value.nadir.map((d) => (
+
+ ))}
+
+
+ ) : null}
+
+ );
+}
+
+export function IllnessCorrelationCard({ episodeId }: { episodeId: string }) {
+ const { t } = useTranslations();
+ const { data, isLoading, isError } = useIllnessCorrelation(episodeId);
+
+ return (
+
+
+
+
+
+ {t("illness.correlation.title")}
+
+
+
+
+ {isLoading ? (
+
+
+
+
+
+ ) : isError || !data || data.status !== "ok" || !data.value ? (
+
+ {t("illness.correlation.learning")}
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/src/components/illness/illness-episode-detail.tsx b/src/components/illness/illness-episode-detail.tsx
new file mode 100644
index 000000000..4ad5f5e7f
--- /dev/null
+++ b/src/components/illness/illness-episode-detail.tsx
@@ -0,0 +1,142 @@
+"use client";
+
+/**
+ * v1.18.1 — per-episode detail surface (`/illness/[id]`), mirroring
+ * `/labs/[biomarkerId]`. Carries the heading + neutral status, a "log a day"
+ * entry, the stored note, an Edit affordance (the same capture sheet
+ * pre-filled), and the retrospective correlation card (recovery-gap /
+ * red-flag / pre-onset / nadir). Calm + retrospective — no prediction UI, no
+ * alarming colour.
+ */
+import { useState } from "react";
+
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
+import { useTranslations, useFormatters } from "@/lib/i18n/context";
+
+import { IllnessCorrelationCard } from "./illness-correlation-card";
+import { LogDaySheet } from "./log-day-sheet";
+import { NewEpisodeSheet } from "./new-episode-sheet";
+import { EpisodeMenu } from "./episode-menu";
+import { useIllnessEpisode, useResolveEpisode } from "./use-illness";
+
+/** Today as YYYY-MM-DD in the viewer's local calendar. */
+function todayLocal(): string {
+ const now = new Date();
+ const y = now.getFullYear();
+ const m = String(now.getMonth() + 1).padStart(2, "0");
+ const d = String(now.getDate()).padStart(2, "0");
+ return `${y}-${m}-${d}`;
+}
+
+export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
+ const { t } = useTranslations();
+ const fmt = useFormatters();
+ const { data: episode, isLoading, isError } = useIllnessEpisode(episodeId);
+ const resolve = useResolveEpisode();
+
+ const [logOpen, setLogOpen] = useState(false);
+ const [editOpen, setEditOpen] = useState(false);
+ const today = todayLocal();
+
+ if (isError) {
+ return (
+
+ {t("illness.loadError")}
+
+ );
+ }
+
+ const active = episode ? episode.resolvedAt === null : false;
+ const isChronic = episode?.lifecycle === "CHRONIC_ONGOING";
+
+ return (
+
+
+
+
+ {episode?.label ?? }
+
+ {episode ? (
+
+
+ {t(`illness.type.${episode.type}`)}
+
+
+ {active
+ ? isChronic
+ ? t("illness.status.ongoing")
+ : t("illness.status.active")
+ : t("illness.status.recovered")}
+
+
+ {t("illness.onsetOn", {
+ date: fmt.dateShort(new Date(episode.onsetAt)),
+ })}
+ {episode.resolvedAt
+ ? ` · ${t("illness.recoveredOn", {
+ date: fmt.dateShort(new Date(episode.resolvedAt)),
+ })}`
+ : ""}
+
+
+ ) : null}
+
+
+ setLogOpen(true)} className="min-h-11 sm:min-h-9">
+ {t("illness.logDay")}
+
+ {episode ? (
+ setEditOpen(true)}
+ onResolve={
+ active && !isChronic ? () => resolve.mutate(episode.id) : undefined
+ }
+ resolving={resolve.isPending}
+ />
+ ) : null}
+
+
+
+ {episode?.note ? (
+
+
+
+ {t("illness.detail.note")}
+
+
+ {episode.note}
+
+
+
+ ) : null}
+
+ {isLoading ? (
+
+ ) : (
+
+ )}
+
+ {episode ? (
+ <>
+
+
+ >
+ ) : null}
+
+ );
+}
diff --git a/src/components/illness/illness-insights-card.tsx b/src/components/illness/illness-insights-card.tsx
new file mode 100644
index 000000000..fd627f385
--- /dev/null
+++ b/src/components/illness/illness-insights-card.tsx
@@ -0,0 +1,64 @@
+"use client";
+
+/**
+ * v1.18.1 P3 — the cross-episode retrospective summary card ("Your illness
+ * history"): how many episodes in the window + the typical recovery gap, with
+ * a calm "still learning" line until enough resolved episodes back the figure.
+ * Retrospective only — a count of the past, never a forecast.
+ */
+import { History } from "lucide-react";
+
+import { Card, CardContent, CardHeader } from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
+import { useTranslations } from "@/lib/i18n/context";
+
+import { useIllnessInsights } from "./use-illness";
+
+export function IllnessInsightsCard({ windowDays = 365 }: { windowDays?: number }) {
+ const { t } = useTranslations();
+ const { data, isLoading, isError } = useIllnessInsights(windowDays);
+
+ return (
+
+
+
+
+
+ {t("illness.insights.title")}
+
+
+
+
+ {isLoading ? (
+
+
+
+
+ ) : isError || !data ? (
+
+ {t("illness.insights.none")}
+
+ ) : data.episodeCount === 0 ? (
+
+ {t("illness.insights.none")}
+
+ ) : (
+
+
+ {data.episodeCount === 1
+ ? t("illness.insights.countOne")
+ : t("illness.insights.countOther", { count: data.episodeCount })}
+
+
+ {data.typicalRecoveryGapDays !== null
+ ? t("illness.insights.typicalGap", {
+ days: data.typicalRecoveryGapDays,
+ })
+ : t("illness.insights.typicalGapLearning")}
+
+
+ )}
+
+
+ );
+}
diff --git a/src/components/illness/illness-view.tsx b/src/components/illness/illness-view.tsx
new file mode 100644
index 000000000..890bdda9e
--- /dev/null
+++ b/src/components/illness/illness-view.tsx
@@ -0,0 +1,201 @@
+"use client";
+
+/**
+ * v1.18.1 — the illness / condition-journal surface.
+ *
+ * A calm, retrospective journal: the episode history (newest first) with a
+ * neutral status Badge (active / recovered — never an alarming colour, the
+ * med-card rule generalised), a shared overflow kebab (Edit / Mark recovered /
+ * AlertDialog-gated Delete), a "log a day" entry, and the cross-episode
+ * retrospective summary. Each row navigates to the episode-detail surface
+ * (`/illness/[id]`) for the correlation card. Retrospective-only copy — a
+ * journal, not a medical device, and it does not diagnose.
+ */
+import { useState } from "react";
+import Link from "next/link";
+import { ChevronRight, Plus, Stethoscope } from "lucide-react";
+
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card } from "@/components/ui/card";
+import { EmptyState } from "@/components/ui/empty-state";
+import { Skeleton } from "@/components/ui/skeleton";
+import { useTranslations, useFormatters } from "@/lib/i18n/context";
+
+import { LogDaySheet } from "./log-day-sheet";
+import { NewEpisodeSheet } from "./new-episode-sheet";
+import { EpisodeMenu } from "./episode-menu";
+import { IllnessInsightsCard } from "./illness-insights-card";
+import { useIllnessEpisodes, useResolveEpisode } from "./use-illness";
+import type { IllnessEpisodeDTO } from "./types";
+
+/** Today as a YYYY-MM-DD string in the viewer's local calendar. */
+function todayLocal(): string {
+ const now = new Date();
+ const y = now.getFullYear();
+ const m = String(now.getMonth() + 1).padStart(2, "0");
+ const d = String(now.getDate()).padStart(2, "0");
+ return `${y}-${m}-${d}`;
+}
+
+function EpisodeRow({
+ episode,
+ onLogDay,
+ onEdit,
+ onResolve,
+ resolving,
+}: {
+ episode: IllnessEpisodeDTO;
+ onLogDay: (id: string) => void;
+ onEdit: (episode: IllnessEpisodeDTO) => void;
+ onResolve: (id: string) => void;
+ resolving: boolean;
+}) {
+ const { t } = useTranslations();
+ const fmt = useFormatters();
+ const active = episode.resolvedAt === null;
+ const isChronic = episode.lifecycle === "CHRONIC_ONGOING";
+
+ return (
+
+
+
+ {episode.label}
+ {t(`illness.type.${episode.type}`)}
+
+ {active
+ ? isChronic
+ ? t("illness.status.ongoing")
+ : t("illness.status.active")
+ : t("illness.status.recovered")}
+
+
+
+ {t("illness.onsetOn", { date: fmt.dateShort(new Date(episode.onsetAt)) })}
+ {episode.resolvedAt
+ ? ` · ${t("illness.recoveredOn", {
+ date: fmt.dateShort(new Date(episode.resolvedAt)),
+ })}`
+ : ""}
+
+
+
+ onLogDay(episode.id)}
+ >
+ {t("illness.logDay")}
+
+ onEdit(episode)}
+ onResolve={
+ active && !isChronic ? () => onResolve(episode.id) : undefined
+ }
+ resolving={resolving}
+ />
+
+
+
+ );
+}
+
+export function IllnessView() {
+ const { t } = useTranslations();
+ const { data: episodes, isLoading } = useIllnessEpisodes(true);
+ const resolve = useResolveEpisode();
+
+ const today = todayLocal();
+ const [newOpen, setNewOpen] = useState(false);
+ const [editEpisode, setEditEpisode] = useState(null);
+ const [logEpisodeId, setLogEpisodeId] = useState(null);
+
+ const logEpisode = episodes?.find((e) => e.id === logEpisodeId) ?? null;
+
+ return (
+
+
+
+
+ {t("illness.title")}
+
+
+ {t("illness.subtitle")}
+
+
+
setNewOpen(true)}
+ className="min-h-11 shrink-0 sm:min-h-9"
+ >
+
+ {t("illness.newEpisode")}
+
+
+
+
{t("illness.disclaimer")}
+
+ {isLoading ? (
+
+
+
+
+
+ ) : episodes && episodes.length > 0 ? (
+ <>
+
+ {episodes.map((episode) => (
+ setLogEpisodeId(id)}
+ onEdit={(e) => setEditEpisode(e)}
+ onResolve={(id) => resolve.mutate(id)}
+ />
+ ))}
+
+
+ >
+ ) : (
+
}
+ title={t("illness.empty.title")}
+ description={t("illness.empty.body")}
+ ctaSize="lg"
+ action={
+
setNewOpen(true)}>
+
+ {t("illness.newEpisode")}
+
+ }
+ />
+ )}
+
+
+
{
+ if (!open) setEditEpisode(null);
+ }}
+ today={today}
+ editEpisode={editEpisode ?? undefined}
+ />
+ {logEpisodeId ? (
+ {
+ if (!open) setLogEpisodeId(null);
+ }}
+ episodeId={logEpisodeId}
+ date={today}
+ onsetDate={logEpisode?.onsetAt.slice(0, 10)}
+ />
+ ) : null}
+
+ );
+}
diff --git a/src/components/illness/log-day-sheet.tsx b/src/components/illness/log-day-sheet.tsx
new file mode 100644
index 000000000..4f59518ec
--- /dev/null
+++ b/src/components/illness/log-day-sheet.tsx
@@ -0,0 +1,389 @@
+"use client";
+
+/**
+ * v1.18.1 — illness day-log sheet. The daily symptom timeline for one
+ * episode-day: ~8 Jackson/WURSS symptom chips with a 0–3 severity, a
+ * coarse functional-impact chip group (0 = fully functional … 3 =
+ * bedbound), an optional fever reading, and an encrypted free-text note.
+ *
+ * Structurally a fork of the cycle `log-day-sheet`: the shared
+ * `ResponsiveSheet` + `SheetSection` primitives, a `Chip`/`SymptomChip`
+ * vocabulary, the envelope-aware pre-fill, and the `["illness"]`-prefix
+ * invalidation on write. Neutral palette throughout — no alarming colour;
+ * status reads via discreet text, never a red tint (the med-card rule
+ * generalised).
+ */
+import { useMemo, useState } from "react";
+import { Activity, CalendarDays, NotebookPen, Thermometer } from "lucide-react";
+
+import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
+import {
+ SheetSection,
+ SheetSectionCount,
+} from "@/components/ui/sheet-section";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { cn } from "@/lib/utils";
+import { useTranslations } from "@/lib/i18n/context";
+
+import { ILLNESS_SYMPTOM_CATALOG } from "./symptom-catalog";
+import { useIllnessDayLog, useUpsertDayLog } from "./use-illness";
+import type { IllnessDayLogInput } from "./types";
+
+interface LogDaySheetProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ episodeId: string;
+ /** The day to seed the picker with (usually today). */
+ date: string;
+ /**
+ * Episode onset day (`YYYY-MM-DD`) — the earliest day the picker allows, so
+ * a retrospective journal can backdate a day within the episode window
+ * (a user ill Sat–Sun who opens Monday can still record either day).
+ */
+ onsetDate?: string;
+}
+
+/** Mini toggle chip — the shared selection vocabulary. */
+function Chip({
+ active,
+ onClick,
+ children,
+}: {
+ active: boolean;
+ onClick: () => void;
+ children: React.ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+/** A symptom chip with an inline 0–3 severity selector once active. */
+function SymptomChip({
+ label,
+ icon: Icon,
+ active,
+ severity,
+ onToggle,
+ onSeverity,
+}: {
+ label: string;
+ icon: React.ComponentType<{ className?: string }>;
+ active: boolean;
+ severity: number | null;
+ onToggle: () => void;
+ onSeverity: (value: number) => void;
+}) {
+ const { t } = useTranslations();
+ return (
+
+
+
+ {label}
+
+ {active ? (
+
+ {[1, 2, 3].map((value) => (
+ onSeverity(value)}
+ className={cn(
+ "h-6 w-6 rounded-md border text-xs",
+ severity === value
+ ? "border-primary bg-primary/10 text-foreground"
+ : "border-border text-muted-foreground hover:bg-muted",
+ )}
+ >
+ {value}
+
+ ))}
+
+ ) : null}
+
+ );
+}
+
+export function LogDaySheet({
+ open,
+ onOpenChange,
+ episodeId,
+ date,
+ onsetDate,
+}: LogDaySheetProps) {
+ const { t } = useTranslations();
+
+ // The day being logged — seeded from `date` (today), but the picker lets the
+ // user backdate within [onset, today] for the retrospective journal.
+ const [selectedDate, setSelectedDate] = useState(date);
+ // Re-seed the picker each time the sheet opens (the open transition).
+ const [pickerWasOpen, setPickerWasOpen] = useState(false);
+ if (open && !pickerWasOpen) {
+ setPickerWasOpen(true);
+ setSelectedDate(date);
+ } else if (!open && pickerWasOpen) {
+ setPickerWasOpen(false);
+ }
+
+ const dayLog = useIllnessDayLog(open ? episodeId : null, selectedDate);
+ const upsert = useUpsertDayLog(episodeId);
+
+ const [symptoms, setSymptoms] = useState>(
+ new Map(),
+ );
+ const [functionalImpact, setFunctionalImpact] = useState(null);
+ const [fever, setFever] = useState("");
+ const [note, setNote] = useState("");
+
+ // Hydrate the form from the fetched DTO once per (open, date, fetched-row)
+ // tuple — React's recommended "adjust state during render keyed on a prop"
+ // alternative to a setState-in-effect (the cycle log-day-sheet precedent).
+ // `dto === null` (nothing logged) resets to blank.
+ const dto = open ? (dayLog.data ?? null) : undefined;
+ const formKey = open
+ ? `${episodeId}:${selectedDate}:${dayLog.isSuccess ? "loaded" : "loading"}`
+ : null;
+ const [lastFormKey, setLastFormKey] = useState(null);
+ if (open && formKey !== lastFormKey && !dayLog.isLoading) {
+ setLastFormKey(formKey);
+ setSymptoms(
+ new Map((dto?.symptoms ?? []).map((s) => [s.key, s.severity ?? null])),
+ );
+ setFunctionalImpact(dto?.functionalImpact ?? null);
+ setFever(dto?.feverC != null ? String(dto.feverC) : "");
+ setNote(dto?.note ?? "");
+ } else if (!open && lastFormKey !== null) {
+ setLastFormKey(null);
+ }
+
+ const symptomCount = symptoms.size;
+
+ function toggleSymptom(key: string) {
+ setSymptoms((prev) => {
+ const next = new Map(prev);
+ if (next.has(key)) next.delete(key);
+ else next.set(key, null);
+ return next;
+ });
+ }
+
+ function setSeverity(key: string, value: number) {
+ setSymptoms((prev) => {
+ const next = new Map(prev);
+ next.set(key, prev.get(key) === value ? null : value);
+ return next;
+ });
+ }
+
+ const feverValue = useMemo(() => {
+ if (fever.trim() === "") return null;
+ const parsed = Number.parseFloat(fever.replace(",", "."));
+ return Number.isFinite(parsed) ? parsed : null;
+ }, [fever]);
+
+ async function handleSave() {
+ const input: IllnessDayLogInput = {
+ date: selectedDate,
+ functionalImpact,
+ feverC: feverValue,
+ symptoms: Array.from(symptoms.entries()).map(([key, severity]) => ({
+ key,
+ severity: severity ?? undefined,
+ })),
+ note: note.trim() === "" ? null : note,
+ };
+ try {
+ await upsert.mutateAsync(input);
+ onOpenChange(false);
+ } catch {
+ // Keep the sheet open; the error strip below reports it.
+ }
+ }
+
+ const impactLabels = [
+ t("illness.impact.0"),
+ t("illness.impact.1"),
+ t("illness.impact.2"),
+ t("illness.impact.3"),
+ ];
+
+ return (
+
+ onOpenChange(false)}>
+ {t("common.cancel")}
+
+
+ {t("common.save")}
+
+ >
+ }
+ >
+
+
}
+ summary={
+
{selectedDate}
+ }
+ defaultOpen
+ >
+
+
+ {t("illness.sheet.day")}
+
+ {
+ if (e.target.value) setSelectedDate(e.target.value);
+ }}
+ className="max-w-44"
+ />
+
+
+
+
}
+ summary={
}
+ defaultOpen
+ >
+
+ {ILLNESS_SYMPTOM_CATALOG.map((s) => (
+ toggleSymptom(s.key)}
+ onSeverity={(value) => setSeverity(s.key, value)}
+ />
+ ))}
+
+
+
+
}
+ summary={
+ functionalImpact != null ? (
+
+ {impactLabels[functionalImpact]}
+
+ ) : null
+ }
+ >
+
+ {[0, 1, 2, 3].map((value) => (
+
+ setFunctionalImpact(
+ functionalImpact === value ? null : value,
+ )
+ }
+ >
+ {impactLabels[value]}
+
+ ))}
+
+
+
+
}
+ summary={
+ feverValue != null ? (
+
+ {feverValue.toFixed(1)} °C
+
+ ) : null
+ }
+ >
+
+
+ {t("illness.sheet.fever")}
+
+ setFever(e.target.value)}
+ className="max-w-32"
+ />
+
+
+
+
}
+ summary={
+ note.trim() !== "" ? (
+
+ {t("illness.sheet.noteSet")}
+
+ ) : null
+ }
+ >
+
+
+
+
+ {upsert.isError ? (
+
+ {t("illness.sheet.saveError")}
+
+ ) : null}
+
+
+ );
+}
diff --git a/src/components/illness/new-episode-sheet.tsx b/src/components/illness/new-episode-sheet.tsx
new file mode 100644
index 000000000..b312d6512
--- /dev/null
+++ b/src/components/illness/new-episode-sheet.tsx
@@ -0,0 +1,258 @@
+"use client";
+
+/**
+ * v1.18.1 — the "new episode" capture sheet. A calm, minimal form: a
+ * label, the broad condition type, the lifecycle, an onset date, and an
+ * optional encrypted note. Retrospective-only — no prediction UI, no
+ * diagnosis prompt. Neutral palette throughout.
+ */
+import { useState } from "react";
+
+import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { useTranslations } from "@/lib/i18n/context";
+
+import { useCreateEpisode, useIllnessEpisodes, useUpdateEpisode } from "./use-illness";
+import type {
+ IllnessEpisodeDTO,
+ IllnessLifecycle,
+ IllnessType,
+} from "./types";
+
+const TYPES: IllnessType[] = [
+ "INFECTION",
+ "ALLERGY",
+ "INJURY",
+ "MENTAL_HEALTH",
+ "AUTOIMMUNE",
+ "CHRONIC",
+ "OTHER",
+];
+
+const LIFECYCLES: IllnessLifecycle[] = [
+ "ACUTE",
+ "CHRONIC_ONGOING",
+ "RECURRING",
+ "FLARE",
+];
+
+/** Lifecycles that may hang off a parent condition. */
+const PARENTABLE: IllnessLifecycle[] = ["FLARE", "RECURRING"];
+
+const NONE = "__none__";
+
+interface NewEpisodeSheetProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ today: string;
+ /** When set, the sheet edits this episode instead of creating a new one. */
+ editEpisode?: IllnessEpisodeDTO;
+}
+
+export function NewEpisodeSheet({
+ open,
+ onOpenChange,
+ today,
+ editEpisode,
+}: NewEpisodeSheetProps) {
+ const { t } = useTranslations();
+ const create = useCreateEpisode();
+ const update = useUpdateEpisode();
+ const isEdit = editEpisode !== undefined;
+ const pending = create.isPending || update.isPending;
+ const isError = create.isError || update.isError;
+
+ // Candidate parents: live episodes other than the one being edited.
+ const { data: allEpisodes } = useIllnessEpisodes(true);
+ const parentCandidates = (allEpisodes ?? []).filter(
+ (e) => e.id !== editEpisode?.id,
+ );
+
+ const [label, setLabel] = useState("");
+ const [type, setType] = useState("INFECTION");
+ const [lifecycle, setLifecycle] = useState("ACUTE");
+ const [onset, setOnset] = useState(today);
+ const [parentId, setParentId] = useState(NONE);
+ const [note, setNote] = useState("");
+
+ // Reset / hydrate the form each time the sheet opens — adjusted during
+ // render keyed on the open transition (React's recommended alternative to a
+ // setState-in-effect).
+ const [wasOpen, setWasOpen] = useState(false);
+ if (open && !wasOpen) {
+ setWasOpen(true);
+ setLabel(editEpisode?.label ?? "");
+ setType(editEpisode?.type ?? "INFECTION");
+ setLifecycle(editEpisode?.lifecycle ?? "ACUTE");
+ setOnset(editEpisode ? editEpisode.onsetAt.slice(0, 10) : today);
+ setParentId(editEpisode?.parentConditionId ?? NONE);
+ setNote(editEpisode?.note ?? "");
+ } else if (!open && wasOpen) {
+ setWasOpen(false);
+ }
+
+ const showParent = PARENTABLE.includes(lifecycle);
+
+ async function handleSave() {
+ if (label.trim() === "") return;
+ const onsetAt = new Date(`${onset}T12:00:00`).toISOString();
+ const parentConditionId = showParent && parentId !== NONE ? parentId : null;
+ try {
+ if (isEdit && editEpisode) {
+ await update.mutateAsync({
+ id: editEpisode.id,
+ input: {
+ label: label.trim(),
+ type,
+ lifecycle,
+ onsetAt,
+ parentConditionId,
+ note: note.trim() === "" ? null : note,
+ },
+ });
+ } else {
+ await create.mutateAsync({
+ label: label.trim(),
+ type,
+ lifecycle,
+ // Anchor to local noon so the UTC instant lands on the intended
+ // calendar day regardless of the viewer's offset.
+ onsetAt,
+ parentConditionId,
+ note: note.trim() === "" ? null : note,
+ });
+ }
+ onOpenChange(false);
+ } catch {
+ // Keep the sheet open; the error strip reports it.
+ }
+ }
+
+ return (
+
+ onOpenChange(false)}>
+ {t("common.cancel")}
+
+
+ {t("common.save")}
+
+ >
+ }
+ >
+
+
+ {t("illness.new.label")}
+ setLabel(e.target.value)}
+ placeholder={t("illness.new.labelPlaceholder")}
+ />
+
+
+
+ {t("illness.new.type")}
+ setType(v as IllnessType)}>
+
+
+
+
+ {TYPES.map((value) => (
+
+ {t(`illness.type.${value}`)}
+
+ ))}
+
+
+
+
+
+ {t("illness.new.lifecycle")}
+ setLifecycle(v as IllnessLifecycle)}
+ >
+
+
+
+
+ {LIFECYCLES.map((value) => (
+
+ {t(`illness.lifecycle.${value}`)}
+
+ ))}
+
+
+
+
+ {showParent ? (
+
+ {t("illness.new.parent")}
+
+
+
+
+
+ {t("illness.new.parentNone")}
+ {parentCandidates.map((e) => (
+
+ {e.label}
+
+ ))}
+
+
+
+ ) : null}
+
+
+ {t("illness.new.onset")}
+ setOnset(e.target.value)}
+ className="max-w-44"
+ />
+
+
+
+ {t("illness.new.note")}
+
+
+ {isError ? (
+
+ {t("illness.new.saveError")}
+
+ ) : null}
+
+
+ );
+}
diff --git a/src/components/illness/symptom-catalog.ts b/src/components/illness/symptom-catalog.ts
new file mode 100644
index 000000000..dcba823c5
--- /dev/null
+++ b/src/components/illness/symptom-catalog.ts
@@ -0,0 +1,48 @@
+/**
+ * v1.18.1 — the seeded illness-symptom catalog, mirrored client-side.
+ *
+ * The keys are fixed at the migration seed (0171). The `key` is the
+ * snake_case DB key the day-log write resolves against; the `labelKey` is
+ * the i18n leaf under `illness.symptom.*`; the `icon` is the Lucide name
+ * from the seed. Keep this in lockstep with the seed in
+ * `prisma/migrations/0171_v1181_illness_episode/migration.sql` and the
+ * `illness.symptom.*` i18n leaves.
+ *
+ * The set is Jackson / WURSS-derived (~8 cardinal upper-respiratory + viral
+ * symptoms, 0–3 severity). Fever lives on the day row (`feverC`), not as a
+ * symptom chip; functional impact is a separate slider.
+ */
+import {
+ BatteryLow,
+ Brain,
+ Droplets,
+ Flame,
+ Megaphone,
+ PersonStanding,
+ Waves,
+ Wind,
+ type LucideIcon,
+} from "lucide-react";
+
+export interface IllnessSymptom {
+ /** snake_case DB key — the value sent in `symptoms[].key`. */
+ key: string;
+ /** i18n leaf under `illness.symptom.*`. */
+ labelKey: string;
+ icon: LucideIcon;
+}
+
+export const ILLNESS_SYMPTOM_CATALOG: IllnessSymptom[] = [
+ { key: "runny_nose", labelKey: "illness.symptom.runnyNose", icon: Droplets },
+ { key: "stuffy_nose", labelKey: "illness.symptom.stuffyNose", icon: Wind },
+ { key: "sneezing", labelKey: "illness.symptom.sneezing", icon: Waves },
+ { key: "sore_throat", labelKey: "illness.symptom.soreThroat", icon: Flame },
+ { key: "cough", labelKey: "illness.symptom.cough", icon: Megaphone },
+ { key: "headache", labelKey: "illness.symptom.headache", icon: Brain },
+ {
+ key: "body_aches",
+ labelKey: "illness.symptom.bodyAches",
+ icon: PersonStanding,
+ },
+ { key: "fatigue", labelKey: "illness.symptom.fatigue", icon: BatteryLow },
+];
diff --git a/src/components/illness/types.ts b/src/components/illness/types.ts
new file mode 100644
index 000000000..04ee34c1a
--- /dev/null
+++ b/src/components/illness/types.ts
@@ -0,0 +1,141 @@
+/**
+ * Client-side illness DTO mirrors (v1.18.1) — the wire shapes the
+ * `/api/illness/*` routes return. Kept in lockstep with
+ * `src/lib/illness/dto.ts` (the server-authoritative source).
+ */
+export type IllnessType =
+ | "INFECTION"
+ | "ALLERGY"
+ | "INJURY"
+ | "MENTAL_HEALTH"
+ | "AUTOIMMUNE"
+ | "CHRONIC"
+ | "OTHER";
+
+export type IllnessLifecycle =
+ | "ACUTE"
+ | "CHRONIC_ONGOING"
+ | "RECURRING"
+ | "FLARE";
+
+export interface IllnessSymptomSelection {
+ key: string;
+ severity?: number | null;
+}
+
+export interface IllnessEpisodeDTO {
+ id: string;
+ label: string;
+ type: IllnessType;
+ lifecycle: IllnessLifecycle;
+ onsetAt: string;
+ resolvedAt: string | null;
+ parentConditionId: string | null;
+ note: string | null;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface IllnessDayLogDTO {
+ id: string;
+ episodeId: string;
+ date: string;
+ functionalImpact: number | null;
+ feverC: number | null;
+ symptoms: IllnessSymptomSelection[];
+ note: string | null;
+ updatedAt: string;
+}
+
+export interface IllnessEpisodeCreateInput {
+ label: string;
+ type: IllnessType;
+ lifecycle?: IllnessLifecycle;
+ onsetAt?: string;
+ parentConditionId?: string | null;
+ note?: string | null;
+}
+
+export interface IllnessDayLogInput {
+ date: string;
+ functionalImpact?: number | null;
+ feverC?: number | null;
+ symptoms?: IllnessSymptomSelection[];
+ note?: string | null;
+}
+
+export interface IllnessEpisodeUpdateInput {
+ label?: string;
+ type?: IllnessType;
+ lifecycle?: IllnessLifecycle;
+ onsetAt?: string;
+ resolvedAt?: string | null;
+ parentConditionId?: string | null;
+ note?: string | null;
+}
+
+/* ── P3 correlation + retrospective DTOs (server-authoritative) ───────── */
+
+export interface IllnessVitalDeviation {
+ type: string;
+ day: string;
+ value: number;
+ baselineCenter: number;
+ deviationSd: number;
+ direction: "above" | "below";
+ adverse: boolean;
+}
+
+export interface IllnessVitalReturn {
+ type: string;
+ returnedDay: string | null;
+ gapDays: number | null;
+}
+
+export interface IllnessRedFlag {
+ type: string;
+ reason: "sustained_low_spo2" | "sustained_fever";
+ worstValue: number;
+ days: number;
+}
+
+export interface IllnessCorrelationValue {
+ episodeId: string;
+ preOnset: IllnessVitalDeviation[];
+ nadir: IllnessVitalDeviation[];
+ returns: IllnessVitalReturn[];
+ recoveryGapDays: number | null;
+ feltBetterDay: string | null;
+ redFlags: IllnessRedFlag[];
+}
+
+/** The flat `Derived` wire shape the correlation route returns. */
+export interface IllnessCorrelationResponse {
+ episodeId: string;
+ status: "ok" | "insufficient";
+ value: IllnessCorrelationValue | null;
+ coverage: {
+ requiredInputs: number;
+ presentInputs: number;
+ historyDays: number;
+ missing: string[];
+ };
+ confidence: { score: number; band: string } | null;
+ provenance: {
+ inputs: string[];
+ source: string;
+ windowDays: number;
+ computedAt: string;
+ };
+ reason: string | null;
+}
+
+export interface IllnessInsightsResponse {
+ windowDays: number;
+ episodeCount: number;
+ resolvedCount: number;
+ typicalRecoveryGapDays: number | null;
+ gapSampleSize: number;
+ byMonth: Record;
+ byType: Record;
+}
diff --git a/src/components/illness/use-illness.ts b/src/components/illness/use-illness.ts
new file mode 100644
index 000000000..613734720
--- /dev/null
+++ b/src/components/illness/use-illness.ts
@@ -0,0 +1,142 @@
+"use client";
+
+/**
+ * v1.18.1 — illness / condition-journal read + write hooks.
+ *
+ * Reads unwrap the envelope `data` (via `apiGet`/`apiPost`) per the project
+ * rule; every key is factory-routed through `queryKeys.illness*`. Writes
+ * invalidate the whole `["illness"]` prefix so the episode history list and
+ * an open day-log sheet repaint in lockstep after a log.
+ */
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+
+import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
+import { queryKeys } from "@/lib/query-keys";
+
+import type {
+ IllnessCorrelationResponse,
+ IllnessDayLogDTO,
+ IllnessDayLogInput,
+ IllnessEpisodeCreateInput,
+ IllnessEpisodeDTO,
+ IllnessEpisodeUpdateInput,
+ IllnessInsightsResponse,
+} from "./types";
+
+/** Newest-first episode list. */
+export function useIllnessEpisodes(includeResolved = true) {
+ return useQuery({
+ queryKey: queryKeys.illnessEpisodes(includeResolved),
+ queryFn: () =>
+ apiGet(
+ `/api/illness/episodes?includeResolved=${includeResolved}`,
+ ),
+ });
+}
+
+/** One episode by id. */
+export function useIllnessEpisode(id: string | null) {
+ return useQuery({
+ queryKey: queryKeys.illnessEpisode(id ?? "none"),
+ enabled: id !== null,
+ queryFn: () => apiGet(`/api/illness/episodes/${id}`),
+ });
+}
+
+/**
+ * The per-episode retrospective correlation (recovery-gap / red-flag /
+ * pre-onset / nadir). Server-authoritative + coverage-gated — the surface
+ * pattern-matches `status` ("insufficient" → "still learning"), never
+ * recomputes.
+ */
+export function useIllnessCorrelation(episodeId: string | null) {
+ return useQuery({
+ queryKey: queryKeys.illnessCorrelation(episodeId ?? "none"),
+ enabled: episodeId !== null,
+ queryFn: () =>
+ apiGet(
+ `/api/illness/episodes/${episodeId}/correlation`,
+ ),
+ });
+}
+
+/** The cross-episode retrospective summary over a trailing window in days. */
+export function useIllnessInsights(windowDays = 365) {
+ return useQuery({
+ queryKey: queryKeys.illnessInsights(windowDays),
+ queryFn: () =>
+ apiGet(
+ `/api/illness/insights?windowDays=${windowDays}`,
+ ),
+ });
+}
+
+/** One episode's day-log for a given date (null when nothing is logged). */
+export function useIllnessDayLog(episodeId: string | null, date: string) {
+ return useQuery({
+ queryKey: queryKeys.illnessDayLog(episodeId ?? "none", date),
+ enabled: episodeId !== null,
+ queryFn: () =>
+ apiGet(
+ `/api/illness/episodes/${episodeId}/day-logs?date=${date}`,
+ ),
+ });
+}
+
+function useInvalidateIllness() {
+ const qc = useQueryClient();
+ return () => qc.invalidateQueries({ queryKey: queryKeys.illness() });
+}
+
+/** Create an episode. */
+export function useCreateEpisode() {
+ const invalidate = useInvalidateIllness();
+ return useMutation({
+ mutationFn: (input: IllnessEpisodeCreateInput) =>
+ apiPost("/api/illness/episodes", input),
+ onSuccess: invalidate,
+ });
+}
+
+/** Edit an episode (partial). */
+export function useUpdateEpisode() {
+ const invalidate = useInvalidateIllness();
+ return useMutation({
+ mutationFn: ({ id, input }: { id: string; input: IllnessEpisodeUpdateInput }) =>
+ apiPatch(`/api/illness/episodes/${id}`, input),
+ onSuccess: invalidate,
+ });
+}
+
+/** Mark an episode recovered. */
+export function useResolveEpisode() {
+ const invalidate = useInvalidateIllness();
+ return useMutation({
+ mutationFn: (id: string) =>
+ apiPatch(`/api/illness/episodes/${id}/resolve`, {}),
+ onSuccess: invalidate,
+ });
+}
+
+/** Soft-delete an episode. */
+export function useDeleteEpisode() {
+ const invalidate = useInvalidateIllness();
+ return useMutation({
+ mutationFn: (id: string) =>
+ apiDelete(`/api/illness/episodes/${id}`),
+ onSuccess: invalidate,
+ });
+}
+
+/** Upsert one day-log on an episode. */
+export function useUpsertDayLog(episodeId: string) {
+ const invalidate = useInvalidateIllness();
+ return useMutation({
+ mutationFn: (input: IllnessDayLogInput) =>
+ apiPost(
+ `/api/illness/episodes/${episodeId}/day-logs`,
+ input,
+ ),
+ onSuccess: invalidate,
+ });
+}
diff --git a/src/components/insights/__tests__/coincident-deviation-card.test.tsx b/src/components/insights/__tests__/coincident-deviation-card.test.tsx
index 9b64d26e0..ace97f219 100644
--- a/src/components/insights/__tests__/coincident-deviation-card.test.tsx
+++ b/src/components/insights/__tests__/coincident-deviation-card.test.tsx
@@ -41,13 +41,16 @@ function deviation(
}
function ok(
- value: CoincidentDeviationValue,
+ value: Omit &
+ Partial>,
band: "high" | "medium" | "low" | "draft" = "high",
): Resp {
return {
metric: "COINCIDENT_DEVIATION",
status: "ok",
- value,
+ // v1.18.1 P4 — default the Rest Mode reframe flag off so the existing
+ // fixtures stay valid; the reframe test passes `illnessExplained: true`.
+ value: { illnessExplained: false, ...value },
coverage: {
requiredInputs: 5,
presentInputs: 5,
@@ -202,6 +205,32 @@ describe("", () => {
expect(html).not.toContain('role="alert"');
});
+ it("reframes a fired flag as illness-explained during Rest Mode (no extra alarm)", () => {
+ mock(
+ ok({
+ fired: true,
+ day: "2026-06-03",
+ illnessExplained: true,
+ vitals: [
+ deviation("RESTING_HEART_RATE", true),
+ deviation("RESPIRATORY_RATE", true),
+ ],
+ contributing: [
+ deviation("RESTING_HEART_RATE", true),
+ deviation("RESPIRATORY_RATE", true),
+ ],
+ }),
+ );
+ const html = render( );
+ expect(html).toContain('data-state="fired"');
+ // The reframe line replaces the open-ended "possible factors" copy.
+ expect(html).toContain("the condition you logged");
+ expect(html).not.toContain("Possible factors — never a cause");
+ // Annotate, don't escalate: no new destructive colour beyond the amber the
+ // fired state already carries.
+ expect(html).not.toContain("text-destructive");
+ });
+
it("softens a fired flag to the watch tone when history is thin", () => {
mock(
ok(
diff --git a/src/components/insights/__tests__/device-score-surfaces.test.tsx b/src/components/insights/__tests__/device-score-surfaces.test.tsx
index 537537f3d..73ae8e968 100644
--- a/src/components/insights/__tests__/device-score-surfaces.test.tsx
+++ b/src/components/insights/__tests__/device-score-surfaces.test.tsx
@@ -154,12 +154,13 @@ describe("device-score loading skeletons", () => {
expect(html).not.toContain('data-slot="device-score-tile"');
});
- it("paints the shared Skeleton grid for recovery while loading", () => {
+ it("paints a chart skeleton for recovery while loading", () => {
analyticsMock.mockReturnValue(analyticsLoading());
const html = render( );
expect(html).toContain('data-slot="recovery-loading"');
- expect(html).toContain('data-slot="device-score-grid-skeleton"');
- expect(html).toContain('data-slot="skeleton"');
+ // v1.18.1 — the rebuilt page leads with canonical chart blocks, so the
+ // loading shell reserves a chart skeleton (not the old tile grid).
+ expect(html).toContain('data-slot="chart-skeleton"');
expect(html).not.toContain('data-slot="recovery-empty"');
});
});
@@ -208,7 +209,7 @@ describe(" data-gating", () => {
expect(html).not.toContain('data-slot="recovery-group-strain"');
});
- it("renders the strain group and cross-links the recovery score when present", () => {
+ it("renders a canonical chart block for the present signal", () => {
analyticsMock.mockReturnValue(
analyticsWith({
DAY_STRAIN: summary({ count: 14, latest: 12, mean: 11 }),
@@ -218,8 +219,10 @@ describe(" data-gating", () => {
);
const html = render( );
expect(html).toContain('data-slot="recovery-group-strain"');
- expect(html).toContain('data-metric="DAY_STRAIN"');
- expect(html).toContain('data-slot="recovery-score-link"');
+ expect(html).toContain('data-slot="recovery-block-DAY_STRAIN"');
+ // B3 — the redundant "Recovery score" cross-link block is gone; this
+ // surface is reached from the overview already.
+ expect(html).not.toContain('data-slot="recovery-score-link"');
// The recharge group (ANS charge only) has no data → hidden.
expect(html).not.toContain('data-slot="recovery-group-recharge"');
// Empty note must not show when at least one signal is present.
diff --git a/src/components/insights/__tests__/layout-coach-fab.test.tsx b/src/components/insights/__tests__/layout-coach-fab.test.tsx
index 95d0a3758..92bb3d3e8 100644
--- a/src/components/insights/__tests__/layout-coach-fab.test.tsx
+++ b/src/components/insights/__tests__/layout-coach-fab.test.tsx
@@ -58,13 +58,16 @@ describe("", () => {
// Fixed bottom-right, above the mobile bottom-nav band. The plain
// desktop offset kicks in at `md:` — the same breakpoint where the
// bottom-nav hides (`md:hidden`) — so the FAB never floats mid-air
- // in the 768-1023px band. The right inset mirrors the desktop
- // bottom inset so the corner gap is even.
+ // in the 768-1023px band. v1.18.1 (C5) — the corner inset is
+ // symmetric: right equals bottom at each breakpoint (`right-6`
+ // mobile-band / `md:right-8` desktop mirrors `md:bottom-8`).
expect(html).toContain("fixed right-6");
+ expect(html).toContain("md:right-8");
expect(html).toContain(
"bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)]",
);
- expect(html).toContain("md:bottom-6");
+ expect(html).toContain("md:bottom-8");
+ expect(html).not.toContain("md:bottom-6");
expect(html).not.toContain("lg:bottom-6");
});
diff --git a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx
index 81cda2edb..fc6cea16c 100644
--- a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx
+++ b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx
@@ -146,6 +146,7 @@ describe("", () => {
streaming={{
content: "Looking at your data ",
metricSource: null,
+ suggestion: null,
inProgress: true,
messageId: null,
errorCode: null,
@@ -170,6 +171,7 @@ describe("", () => {
streaming={{
content: "Drafting…",
metricSource: null,
+ suggestion: null,
inProgress: true,
messageId: null,
errorCode: null,
@@ -187,6 +189,7 @@ describe("", () => {
streaming={{
content: "",
metricSource: null,
+ suggestion: null,
inProgress: true,
messageId: null,
errorCode: null,
@@ -203,6 +206,7 @@ describe("", () => {
streaming={{
content: "",
metricSource: null,
+ suggestion: null,
inProgress: false,
messageId: null,
errorCode: "errorProvider",
@@ -243,6 +247,7 @@ describe("", () => {
streaming={{
content: "Looking at your data ",
metricSource: null,
+ suggestion: null,
inProgress: false,
messageId: "m3-streaming",
errorCode: null,
@@ -269,6 +274,7 @@ describe("", () => {
streaming={{
content: "Looking at your data ",
metricSource: null,
+ suggestion: null,
inProgress: true,
messageId: null,
errorCode: null,
@@ -467,6 +473,7 @@ describe("", () => {
streaming={{
content: "",
metricSource: null,
+ suggestion: null,
inProgress: true,
messageId: null,
errorCode: null,
@@ -570,6 +577,7 @@ describe("", () => {
streaming={{
content: "",
metricSource: null,
+ suggestion: null,
inProgress: false,
messageId: null,
errorCode: "coach.budget.exceeded",
@@ -590,6 +598,7 @@ describe("", () => {
streaming={{
content: "",
metricSource: null,
+ suggestion: null,
inProgress: false,
messageId: null,
errorCode: "coach.provider.rate_limited",
@@ -618,6 +627,7 @@ describe("", () => {
streaming={{
content: "",
metricSource: null,
+ suggestion: null,
inProgress: false,
messageId: null,
errorCode: null,
diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx
index 81869d47f..5b229441f 100644
--- a/src/components/insights/coach-panel/coach-conversation.tsx
+++ b/src/components/insights/coach-panel/coach-conversation.tsx
@@ -291,12 +291,23 @@ export function CoachConversation({
>