diff --git a/CHANGELOG.md b/CHANGELOG.md
index 88a560799..2fa30b3d1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,35 @@
## [Unreleased]
+## [1.18.9] — 2026-06-20 — Coach surface, lab-report scanning, and fresher dashboards
+
+A broad UI and data release. It adds two migrations (both additive and nullable — qualitative lab results and Coach token columns) and three additive, back-compatible API changes (see the note below).
+
+### Added
+
+- Coach opens on a calm new-chat surface: a centred greeting and a single composer, then each answer streams in word by word with a soft fade, a collapsible thinking disclosure, and a quiet per-message footer showing the tokens used and the model. The quick-access drawer is unchanged.
+- Scan a paper lab report. With an AI provider connected, upload a photo or PDF and the reader proposes the measurements and biomarkers it finds. Every row goes through a mandatory review — confirm, edit, or discard each one, with duplicate and reference-range hints — and only what you confirm is saved. The uploaded file is held in memory for the read alone and is never stored.
+- Record a qualitative lab result — positive/negative or another text value — where a number does not apply.
+- Sort lab values alphabetically, and delete a biomarker directly from the labs list and detail views.
+- VO2max shows on the dashboard by default, and Oura's VO2max reading is now ingested alongside the existing sources.
+
+### Changed
+
+- The labs surface defaults to the list view, and its tiles share the medication-card layout. The labs and condition-journal settings now follow the same section pattern as the rest of Settings.
+- Preventive-care cards read the last-completed date as "today"/"yesterday" and the next due date as a discreet "today" (green) or "overdue" (orange), and drop the small trend strip.
+- Sleep-debt and chronotype sit side by side, and the daily-briefing card aligns with the surrounding type.
+
+### Fixed
+
+- The dashboard reflects new readings without a hard refresh: an added or background-synced measurement now refreshes the home snapshot — including on Withings and Apple Health sync, and on returning to the tab.
+- The condition journal opens quickly again. The cross-episode correlation runs only when an entry's analysis is opened, and the typical-recovery-gap insight appears only when there is enough real data to support it.
+- The latest-report area shows its age and, when no AI provider is connected, points to where to connect one, instead of presenting an old report as current.
+- The Coach quick-access icon uses a softer tone.
+
+### API
+
+- The Coach stream's `done` frame now carries an optional token `usage`, and a Coach message can carry `tokensUsed` and `model`; an optional `reasoning` frame is reserved. New `GET/POST /api/labs/ocr/{capability,extract,commit}` endpoints; rows committed from a scan carry `source: "OCR"`. All additive and back-compatible.
+
## [1.18.8] — 2026-06-20 — coded condition and lab terminology in the health-record export
An additive export-only release: the FHIR R4 health record now carries standard terminology codes where it can assert them honestly. No migration, no API contract change.
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 12e8aed5c..1a90c78ad 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.8
+ version: 1.18.9
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -4148,11 +4148,14 @@ paths:
summary: Send a Coach turn (streaming reply)
description: "v1.18.0 — sends a user turn and streams the assistant reply as Server-Sent Events. The response is
`text/event-stream`, not JSON: one `data: \\n\\n` frame per event. Frame `type` is one of `token` (a chunk
- of reply text: `{ type, token }`), `provenance` (the evidence envelope: `{ type, metricSource }`), `done` (`{
- type, conversationId, messageId }`), or `error` (`{ type, code, message }`). The HTTP status is 200 even for a
- provider/refusal outcome — clients dispatch on the `error` frame, not the status. Clients ignore unknown frame
- types (additive evolution). Omitting `conversationId` starts a new conversation. Coach-gated; budget- and
- rate-limited. Auth via cookie or Bearer."
+ of reply text: `{ type, token }`), `provenance` (the evidence envelope: `{ type, metricSource }`), `suggestion`
+ (a cadence-suggestion card: `{ type, suggestion }`), `reasoning` (v1.18.9, optional reasoning-summary text: `{
+ type, text }` — emitted only by reasoning-capable providers; absent otherwise), `done` (`{ type, conversationId,
+ messageId, usage? }` — v1.18.9 adds the optional `usage` envelope `{ totalTokens, promptTokens?,
+ completionTokens?, model? }`, server-authoritative; clients display it, never recompute), or `error` (`{ type,
+ code, message }`). The HTTP status is 200 even for a provider/refusal outcome — clients dispatch on the `error`
+ frame, not the status. Clients ignore unknown frame types (additive evolution). Omitting `conversationId` starts
+ a new conversation. Coach-gated; budget- and rate-limited. Auth via cookie or Bearer."
requestBody:
required: true
content:
@@ -6081,6 +6084,10 @@ paths:
maxLength: 120
value:
type: number
+ valueText:
+ type: string
+ minLength: 1
+ maxLength: 120
unit:
type: string
minLength: 1
@@ -6100,7 +6107,6 @@ paths:
- type: string
const: ""
required:
- - value
- takenAt
responses:
"201":
@@ -6174,6 +6180,10 @@ paths:
maxLength: 120
value:
type: number
+ valueText:
+ type: string
+ minLength: 1
+ maxLength: 120
unit:
type: string
minLength: 1
@@ -6254,6 +6264,123 @@ paths:
"401": *a1
"422": *a3
"429": *a4
+ /api/labs/ocr/capability:
+ get:
+ tags:
+ - Labs
+ summary: Probe Lab-OCR availability
+ description: Cheap probe (no provider call) the Labs UI uses to decide whether to show the scan affordance. Reports
+ whether the caller's configured AI provider can read images, why not, and whether PDFs are accepted.
+ responses:
+ "200":
+ description: Capability flags.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/OcrCapabilityEnvelope"
+ "401": *a1
+ "422": *a3
+ "429": *a4
+ /api/labs/ocr/extract:
+ post:
+ tags:
+ - Labs
+ summary: Extract lab readings from a photo or PDF
+ description: Read-only (NOT idempotent) vision extraction. Accepts a multipart `file` (JPEG/PNG/WebP, or PDF on an
+ Anthropic vision provider; ≤ 12 MiB). Gated by AI consent, a 6/hour rate bucket, and the per-day token budget.
+ The raw upload lives in memory only and is never persisted or logged. Returns proposed rows for the mandatory
+ human review screen — nothing is written. Extracted text is treated as untrusted (prompt-injection); the review
+ step is the safety boundary.
+ requestBody:
+ required: true
+ content:
+ multipart/form-data:
+ schema:
+ type: object
+ properties:
+ file:
+ type: string
+ format: binary
+ description: The lab-report image or PDF. Validated by magic-byte MIME sniff, not the wire Content-Type.
+ required:
+ - file
+ responses:
+ "200":
+ description: Proposed rows for review.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/OcrExtractEnvelope"
+ "401": *a1
+ "422": *a3
+ "429": *a4
+ /api/labs/ocr/commit:
+ post:
+ tags:
+ - Labs
+ summary: Commit confirmed Lab-OCR rows
+ description: 'Writes ONLY the rows the human confirmed on the review screen. Each row resolves-or-mints a user-scoped
+ biomarker and creates a `LabResult` with `source: "OCR"`; a row that duplicates a live reading is skipped.
+ Idempotent (Idempotency-Key). Audits as `labs.ocr.commit`. `userId` is narrowed from the session, never a body
+ field.'
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ rows:
+ minItems: 1
+ maxItems: 100
+ type: array
+ items:
+ type: object
+ properties:
+ analyte:
+ type: string
+ minLength: 1
+ maxLength: 120
+ panel:
+ anyOf:
+ - type: string
+ minLength: 1
+ maxLength: 120
+ - type: string
+ const: ""
+ value:
+ type: number
+ valueText:
+ type: string
+ minLength: 1
+ maxLength: 120
+ unit:
+ type: string
+ minLength: 1
+ maxLength: 40
+ referenceLow:
+ type: number
+ referenceHigh:
+ type: number
+ takenAt:
+ type: string
+ format: date-time
+ pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$
+ required:
+ - analyte
+ - takenAt
+ required:
+ - rows
+ responses:
+ "200":
+ description: Inserted + skipped rows.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/OcrCommitEnvelope"
+ "401": *a1
+ "422": *a3
+ "429": *a4
/api/biomarkers:
get:
tags:
@@ -6897,6 +7024,13 @@ paths:
type: integer
minimum: 30
maximum: 1095
+ - in: query
+ name: includeRecoveryGap
+ schema:
+ type: string
+ enum:
+ - "true"
+ - "false"
responses:
"200":
description: The cross-episode retrospective summary.
@@ -15695,6 +15829,20 @@ components:
- type: string
- type: "null"
description: Coach prompt version that produced the reply; null for user turns.
+ tokensUsed:
+ anyOf:
+ - type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ - type: "null"
+ description: v1.18.9 — total tokens this assistant turn cost, persisted so the per-message token footer survives a
+ reload. Null on user turns, refusals, and pre-feature rows.
+ model:
+ anyOf:
+ - type: string
+ - type: "null"
+ description: v1.18.9 — the provider model that produced the reply (e.g. gpt-4o). Null when unknown (user turns,
+ refusals, older rows).
required:
- id
- role
@@ -15703,6 +15851,8 @@ components:
- metricSource
- providerType
- promptVersion
+ - tokensUsed
+ - model
additionalProperties: false
description: One Coach chat message, decrypted server-side. Ordered oldest-first within a conversation.
CoachProvenance:
@@ -18838,7 +18988,13 @@ components:
analyte:
type: string
value:
- type: number
+ anyOf:
+ - type: number
+ - type: "null"
+ valueText:
+ anyOf:
+ - type: string
+ - type: "null"
unit:
type: string
referenceLow:
@@ -18867,6 +19023,7 @@ components:
- panel
- analyte
- value
+ - valueText
- unit
- referenceLow
- referenceHigh
@@ -18877,10 +19034,12 @@ components:
- createdAt
- updatedAt
additionalProperties: false
- 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`.
+ description: A stored lab result. Exactly one of `value` (numeric) or `valueText` (qualitative, e.g. "negativ") is
+ non-null; `rangeStatus` is always `unknown` for a qualitative row (no number to classify). `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:
@@ -18941,7 +19100,13 @@ components:
analyte:
type: string
value:
- type: number
+ anyOf:
+ - type: number
+ - type: "null"
+ valueText:
+ anyOf:
+ - type: string
+ - type: "null"
unit:
type: string
referenceLow:
@@ -18972,6 +19137,7 @@ components:
- panel
- analyte
- value
+ - valueText
- unit
- referenceLow
- referenceHigh
@@ -19046,6 +19212,264 @@ components:
- data
- error
additionalProperties: false
+ OcrCapabilityEnvelope:
+ type: object
+ properties:
+ data:
+ $ref: "#/components/schemas/OcrCapabilityResponse"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ OcrCapabilityResponse:
+ type: object
+ properties:
+ available:
+ type: boolean
+ reason:
+ anyOf:
+ - type: string
+ enum:
+ - no-provider
+ - text-only-model
+ - type: "null"
+ pdfSupported:
+ type: boolean
+ required:
+ - available
+ - reason
+ - pdfSupported
+ additionalProperties: false
+ description: Whether the caller's configured AI provider can read images (drives the UI's scan affordance). `reason`
+ explains an unavailable state; `pdfSupported` is true only for an Anthropic vision provider (native PDF). No
+ provider call is made.
+ OcrExtractEnvelope:
+ type: object
+ properties:
+ data:
+ $ref: "#/components/schemas/OcrExtractResponse"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ OcrExtractResponse:
+ type: object
+ properties:
+ reportDate:
+ anyOf:
+ - type: string
+ - type: "null"
+ providerType:
+ type: string
+ rows:
+ type: array
+ items:
+ $ref: "#/components/schemas/OcrExtractedRow"
+ required:
+ - reportDate
+ - providerType
+ - rows
+ additionalProperties: false
+ description: The proposed rows for the human review screen. NEVER written to the database — extraction is read-only and
+ the raw upload is held in memory only.
+ OcrExtractedRow:
+ type: object
+ properties:
+ analyte:
+ type: string
+ value:
+ anyOf:
+ - type: number
+ - type: "null"
+ valueText:
+ anyOf:
+ - type: string
+ - type: "null"
+ unit:
+ anyOf:
+ - type: string
+ - type: "null"
+ referenceLow:
+ anyOf:
+ - type: number
+ - type: "null"
+ referenceHigh:
+ anyOf:
+ - type: number
+ - type: "null"
+ takenAt:
+ anyOf:
+ - type: string
+ - type: "null"
+ confidence:
+ type: object
+ properties:
+ analyte:
+ type: number
+ value:
+ type: number
+ unit:
+ type: number
+ range:
+ type: number
+ required:
+ - analyte
+ - value
+ - unit
+ - range
+ additionalProperties: false
+ biomarkerMatch:
+ type: string
+ enum:
+ - new
+ - existing
+ duplicateOf:
+ anyOf:
+ - type: string
+ - type: "null"
+ required:
+ - analyte
+ - value
+ - valueText
+ - unit
+ - referenceLow
+ - referenceHigh
+ - takenAt
+ - confidence
+ - biomarkerMatch
+ - duplicateOf
+ additionalProperties: false
+ description: "One proposed reading transcribed from the upload. UNTRUSTED model output annotated server-side:
+ `biomarkerMatch` flags whether the analyte links an existing catalog marker; `duplicateOf` is the id of a live
+ reading this row likely duplicates (the review row defaults to unchecked when set); `confidence` is the model's
+ per-field self-score (low fields are flagged for the human). Nothing is written until the commit route
+ confirms."
+ OcrCommitEnvelope:
+ type: object
+ properties:
+ data:
+ $ref: "#/components/schemas/OcrCommitResponse"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ OcrCommitResponse:
+ type: object
+ properties:
+ inserted:
+ type: array
+ items:
+ $ref: "#/components/schemas/OcrCommittedLabResult"
+ skipped:
+ type: array
+ items:
+ type: object
+ properties:
+ analyte:
+ type: string
+ reason:
+ type: string
+ const: duplicate
+ required:
+ - analyte
+ - reason
+ additionalProperties: false
+ required:
+ - inserted
+ - skipped
+ additionalProperties: false
+ description: 'The rows written (`source: "OCR"`) and those skipped as commit-time duplicates.'
+ OcrCommittedLabResult:
+ type: object
+ properties:
+ id:
+ type: string
+ biomarkerId:
+ anyOf:
+ - type: string
+ - type: "null"
+ panel:
+ anyOf:
+ - type: string
+ - type: "null"
+ analyte:
+ type: string
+ value:
+ anyOf:
+ - type: number
+ - type: "null"
+ valueText:
+ anyOf:
+ - type: string
+ - type: "null"
+ unit:
+ type: string
+ referenceLow:
+ anyOf:
+ - type: number
+ - type: "null"
+ referenceHigh:
+ anyOf:
+ - type: number
+ - type: "null"
+ takenAt:
+ type: string
+ source:
+ type: string
+ hasNote:
+ type: boolean
+ rangeStatus:
+ type: string
+ enum:
+ - in-range
+ - below
+ - above
+ - unknown
+ createdAt:
+ type: string
+ updatedAt:
+ type: string
+ required:
+ - id
+ - biomarkerId
+ - panel
+ - analyte
+ - value
+ - valueText
+ - unit
+ - referenceLow
+ - referenceHigh
+ - takenAt
+ - source
+ - hasNote
+ - rangeStatus
+ - createdAt
+ - updatedAt
+ additionalProperties: false
ListBiomarkersEnvelope:
type: object
properties:
diff --git a/messages/de.json b/messages/de.json
index 366e5ba2e..40d7ddbd8 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -2010,7 +2010,8 @@
"loadingLabel": "Briefing wird zusammengestellt",
"noProviderTitle": "Kein KI-Anbieter verbunden",
"noProviderDescription": "Das Tagesbriefing fasst deine Werte in Klartext zusammen. Dafür braucht HealthLog einen KI-Anbieter — nutze deinen eigenen Schlüssel (OpenAI, Anthropic) oder ein lokales Modell. Deine Daten bleiben dabei bei dir.",
- "noProviderAction": "Zu den KI-Einstellungen"
+ "noProviderAction": "Zu den KI-Einstellungen",
+ "staleNoProviderHint": "Dieses Briefing kann nicht aktualisiert werden — es ist kein KI-Anbieter verbunden."
},
"trendsRow": {
"title": "Trends",
@@ -2268,7 +2269,15 @@
"dictate": "Diktieren",
"dictateStop": "Diktat beenden",
"showConversations": "Unterhaltungen anzeigen",
- "hideConversations": "Unterhaltungen ausblenden"
+ "hideConversations": "Unterhaltungen ausblenden",
+ "heroGreeting": "Wie kann ich dir helfen?",
+ "heroSubline": "Frag nach deinen Verläufen, Medikamenten oder Messwerten.",
+ "thinkingElapsed": "{seconds}s",
+ "thinkingDuration": "Nachgedacht für {seconds}s",
+ "thinkingExpandAria": "Zeigen, was der Coach berücksichtigt hat",
+ "thinkingDetail": "Beantwortet aus deinem Gesundheits-Snapshot.",
+ "tokensUsed": "{count} Tokens",
+ "tokensUsedWithModel": "{count} Tokens · {model}"
},
"relativeJustNow": "gerade eben",
"relativeMinutesAgoOne": "vor {count} Minute",
@@ -6451,14 +6460,7 @@
"empty": "Keine anstehenden Vorsorge-Erinnerungen.",
"openLink": "Vorsorge öffnen"
},
- "showMeasurements": "Messergebnisse anzeigen",
- "trendStrip": {
- "label": "Verlauf der letzten 7 Tage",
- "summary": "Letzter Wert {value}, {direction} über 7 Tage.",
- "rising": "steigend",
- "falling": "fallend",
- "steady": "stabil"
- }
+ "showMeasurements": "Messergebnisse anzeigen"
},
"moduleList": {
"viewToggleLabel": "Ansicht",
@@ -6492,6 +6494,8 @@
"label": "Reihenfolge der Werte",
"recentDesc": "Neueste Messung zuerst",
"recentAsc": "Älteste Messung zuerst",
+ "alphaAsc": "Name (A–Z)",
+ "alphaDesc": "Name (Z–A)",
"manual": "Manuell sortieren",
"hint": "Bei manueller Sortierung kannst du die Biomarker unten in deine eigene Reihenfolge bringen."
},
@@ -6513,7 +6517,15 @@
"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."
+ "pickBiomarkerError": "Bitte zuerst einen Biomarker auswählen.",
+ "resultType": "Ergebnistyp",
+ "numeric": "Numerisch",
+ "qualitative": "Qualitativ",
+ "qualitativeResult": "Ergebnis",
+ "qualitativePlaceholder": "z. B. negativ",
+ "qualNegative": "negativ",
+ "qualPositive": "positiv",
+ "qualBorderline": "grenzwertig"
},
"backToLabs": "Zurück zu den Laborwerten",
"hasNote": "Notiz",
@@ -6625,7 +6637,44 @@
}
},
"noteLoadFailed": "Notiz konnte nicht geladen werden; sie bleibt beim Speichern unverändert.",
- "showingLatestOf": "Die neuesten {shown} von {total} Messwerten werden angezeigt"
+ "showingLatestOf": "Die neuesten {shown} von {total} Messwerten werden angezeigt",
+ "ocr": {
+ "scanButton": "Befund scannen",
+ "uploadHint": "Foto oder PDF eines Laborbefunds hochladen",
+ "acceptWithPdf": "JPEG, PNG, WebP oder PDF",
+ "acceptImageOnly": "JPEG, PNG oder WebP",
+ "extracting": "Befund wird gelesen…",
+ "reviewTitle": "Vor dem Speichern prüfen",
+ "reviewHint": "Prüfe jeden Wert und speichere die gewünschten Einträge.",
+ "foundCount": "{count} Werte gefunden",
+ "confirmRow": "Diesen Wert übernehmen",
+ "analyteLabel": "Bezeichnung",
+ "resultLabel": "Ergebnis",
+ "valueLabel": "Wert",
+ "unitLabel": "Einheit",
+ "dateLabel": "Datum",
+ "refLowLabel": "Referenz unten",
+ "refHighLabel": "Referenz oben",
+ "newBiomarker": "Neuer Biomarker — wird angelegt",
+ "linksExisting": "Verknüpft mit vorhandenem {name}",
+ "duplicateWarning": "Ein passender Wert existiert bereits",
+ "lowConfidence": "Bitte diesen Wert überprüfen",
+ "valueUnreadable": "Wert unleserlich — bitte manuell eingeben",
+ "saveSelected": "{count} ausgewählte speichern",
+ "discardAll": "Alle verwerfen",
+ "savedToast": "{count} Laborwerte gespeichert",
+ "noRows": "In diesem Befund konnten keine Werte gefunden werden.",
+ "nothingToSave": "Wähle mindestens einen Wert zum Speichern aus.",
+ "saveFailed": "Werte konnten nicht gespeichert werden. Bitte erneut versuchen.",
+ "providerUnsupported": "Dein KI-Anbieter kann keine Bilder lesen. Konfiguriere ein Modell mit Bildverständnis.",
+ "consentRequired": "Für das Scannen eines Befunds ist die Einwilligung zur KI-Verarbeitung erforderlich.",
+ "rateLimited": "Zu viele Scans. Bitte später erneut versuchen.",
+ "budgetExceeded": "Dein KI-Nutzungsbudget für heute ist erreicht.",
+ "fileTooLarge": "Datei ist zu groß (max. 12 MB).",
+ "fileType": "Lade ein JPEG, PNG, WebP oder PDF hoch.",
+ "extractFailed": "Der Befund konnte nicht gelesen werden. Versuche ein schärferes Foto.",
+ "pdfNeedsAnthropic": "PDF-Scannen benötigt einen Claude-Bildanbieter; lade stattdessen ein Foto hoch."
+ }
},
"modules": {
"cycle": {
@@ -6684,6 +6733,10 @@
"illness": {
"title": "Krankheitstagebuch",
"subtitle": "Ein rückblickendes Protokoll deiner Krankheits- und Beschwerde-Episoden.",
+ "settings": {
+ "viewDescription": "Wähle, ob Episoden als Karten oder als kompakte Liste erscheinen.",
+ "orderDescription": "Ziehe deine Episoden in die gewünschte Reihenfolge."
+ },
"customize": "Krankheitstagebuch anpassen",
"newEpisode": "Neue Episode",
"logDay": "Tag erfassen",
@@ -6784,7 +6837,8 @@
"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.",
- "none": "In diesem Zeitraum keine Episoden erfasst."
+ "none": "In diesem Zeitraum keine Episoden erfasst.",
+ "analyze": "Erholungslücke analysieren"
},
"section": {
"active": "Aktiv",
diff --git a/messages/en.json b/messages/en.json
index 45e6a3f98..54241ad76 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -2010,7 +2010,8 @@
"loadingLabel": "Synthesising today's briefing",
"noProviderTitle": "No AI provider connected",
"noProviderDescription": "The daily briefing turns your readings into plain-language summary. HealthLog needs an AI provider for that — use your own key (OpenAI, Anthropic) or a local model. Your data stays with you.",
- "noProviderAction": "Open AI settings"
+ "noProviderAction": "Open AI settings",
+ "staleNoProviderHint": "This briefing can't refresh — no AI provider is connected."
},
"trendsRow": {
"title": "Trends",
@@ -2268,7 +2269,15 @@
"dictate": "Dictate",
"dictateStop": "Stop dictation",
"showConversations": "Show conversations",
- "hideConversations": "Hide conversations"
+ "hideConversations": "Hide conversations",
+ "heroGreeting": "How can I help you?",
+ "heroSubline": "Ask about your trends, medications, or readings.",
+ "thinkingElapsed": "{seconds}s",
+ "thinkingDuration": "Thought for {seconds}s",
+ "thinkingExpandAria": "Show what the Coach considered",
+ "thinkingDetail": "Answered from your health snapshot.",
+ "tokensUsed": "{count} tokens",
+ "tokensUsedWithModel": "{count} tokens · {model}"
},
"relativeJustNow": "just now",
"relativeMinutesAgoOne": "{count} minute ago",
@@ -6451,14 +6460,7 @@
"empty": "No upcoming preventive-care reminders.",
"openLink": "Open preventive care"
},
- "showMeasurements": "Show measurements",
- "trendStrip": {
- "label": "Last 7 days",
- "summary": "Latest value {value}, {direction} over 7 days.",
- "rising": "rising",
- "falling": "falling",
- "steady": "steady"
- }
+ "showMeasurements": "Show measurements"
},
"moduleList": {
"viewToggleLabel": "View",
@@ -6492,6 +6494,8 @@
"label": "Order of values",
"recentDesc": "Most recent reading first",
"recentAsc": "Oldest reading first",
+ "alphaAsc": "Name (A–Z)",
+ "alphaDesc": "Name (Z–A)",
"manual": "Sort manually",
"hint": "With manual sorting you can drag the biomarkers below into your own order."
},
@@ -6513,7 +6517,15 @@
"biomarkerPlaceholder": "Pick a biomarker",
"defineNew": "+ Define new biomarker",
"noBiomarkersHint": "Define a biomarker first to record a value against it.",
- "pickBiomarkerError": "Pick a biomarker first."
+ "pickBiomarkerError": "Pick a biomarker first.",
+ "resultType": "Result type",
+ "numeric": "Numeric",
+ "qualitative": "Qualitative",
+ "qualitativeResult": "Result",
+ "qualitativePlaceholder": "e.g. negative",
+ "qualNegative": "negative",
+ "qualPositive": "positive",
+ "qualBorderline": "borderline"
},
"backToLabs": "Back to lab results",
"hasNote": "Note",
@@ -6625,7 +6637,44 @@
}
},
"noteLoadFailed": "Note could not be loaded; it will be kept unchanged when you save.",
- "showingLatestOf": "Showing the latest {shown} of {total} readings"
+ "showingLatestOf": "Showing the latest {shown} of {total} readings",
+ "ocr": {
+ "scanButton": "Scan a report",
+ "uploadHint": "Upload a photo or PDF of a lab report",
+ "acceptWithPdf": "JPEG, PNG, WebP, or PDF",
+ "acceptImageOnly": "JPEG, PNG, or WebP",
+ "extracting": "Reading your report…",
+ "reviewTitle": "Review before saving",
+ "reviewHint": "Check each reading, then save the ones you want to keep.",
+ "foundCount": "{count} readings found",
+ "confirmRow": "Include this reading",
+ "analyteLabel": "Test name",
+ "resultLabel": "Result",
+ "valueLabel": "Value",
+ "unitLabel": "Unit",
+ "dateLabel": "Date",
+ "refLowLabel": "Reference low",
+ "refHighLabel": "Reference high",
+ "newBiomarker": "New biomarker — will be created",
+ "linksExisting": "Links to existing {name}",
+ "duplicateWarning": "A matching reading already exists",
+ "lowConfidence": "Please double-check this value",
+ "valueUnreadable": "Value unreadable — enter it manually",
+ "saveSelected": "Save {count} selected",
+ "discardAll": "Discard all",
+ "savedToast": "{count} lab results saved",
+ "noRows": "No readings could be found in this report.",
+ "nothingToSave": "Select at least one reading to save.",
+ "saveFailed": "Couldn't save the readings. Please try again.",
+ "providerUnsupported": "Your AI provider can't read images. Configure a vision-capable model.",
+ "consentRequired": "AI processing consent is required to scan a report.",
+ "rateLimited": "Too many scans. Try again later.",
+ "budgetExceeded": "Your AI usage budget for today is reached.",
+ "fileTooLarge": "File is too large (max 12 MB).",
+ "fileType": "Upload a JPEG, PNG, WebP, or PDF.",
+ "extractFailed": "Couldn't read the report. Try a clearer photo.",
+ "pdfNeedsAnthropic": "PDF scanning needs a Claude vision provider; upload a photo instead."
+ }
},
"modules": {
"cycle": {
@@ -6684,6 +6733,10 @@
"illness": {
"title": "Illness journal",
"subtitle": "A retrospective log of your illness and condition episodes.",
+ "settings": {
+ "viewDescription": "Choose whether episodes show as cards or as a compact list.",
+ "orderDescription": "Drag your episodes into the order you want to see them in."
+ },
"customize": "Customize illness journal",
"newEpisode": "New episode",
"logDay": "Log a day",
@@ -6784,7 +6837,8 @@
"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.",
- "none": "No episodes logged in this period."
+ "none": "No episodes logged in this period.",
+ "analyze": "Analyse the recovery gap"
},
"section": {
"active": "Active",
diff --git a/messages/es.json b/messages/es.json
index 983170479..4b8561ed3 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -2010,7 +2010,8 @@
"loadingLabel": "Preparando el resumen",
"noProviderTitle": "Ningún proveedor de IA conectado",
"noProviderDescription": "El briefing diario resume tus lecturas en lenguaje claro. HealthLog necesita un proveedor de IA para ello: usa tu propia clave (OpenAI, Anthropic) o un modelo local. Tus datos siguen siendo tuyos.",
- "noProviderAction": "Abrir ajustes de IA"
+ "noProviderAction": "Abrir ajustes de IA",
+ "staleNoProviderHint": "Este briefing no puede actualizarse: no hay ningún proveedor de IA conectado."
},
"trendsRow": {
"title": "Tendencias",
@@ -2268,7 +2269,15 @@
"dictate": "Dictar",
"dictateStop": "Detener el dictado",
"showConversations": "Mostrar conversaciones",
- "hideConversations": "Ocultar conversaciones"
+ "hideConversations": "Ocultar conversaciones",
+ "heroGreeting": "¿En qué puedo ayudarte?",
+ "heroSubline": "Pregunta por tus tendencias, medicamentos o mediciones.",
+ "thinkingElapsed": "{seconds}s",
+ "thinkingDuration": "Pensó durante {seconds}s",
+ "thinkingExpandAria": "Mostrar lo que el Coach tuvo en cuenta",
+ "thinkingDetail": "Respondido a partir de tu resumen de salud.",
+ "tokensUsed": "{count} tokens",
+ "tokensUsedWithModel": "{count} tokens · {model}"
},
"relativeJustNow": "ahora mismo",
"relativeMinutesAgoOne": "hace {count} minuto",
@@ -6451,14 +6460,7 @@
"empty": "No hay recordatorios de prevención próximos.",
"openLink": "Abrir prevención"
},
- "showMeasurements": "Ver mediciones",
- "trendStrip": {
- "label": "Últimos 7 días",
- "summary": "Último valor {value}, {direction} en 7 días.",
- "rising": "en aumento",
- "falling": "en descenso",
- "steady": "estable"
- }
+ "showMeasurements": "Ver mediciones"
},
"moduleList": {
"viewToggleLabel": "Vista",
@@ -6492,6 +6494,8 @@
"label": "Orden de los valores",
"recentDesc": "Medición más reciente primero",
"recentAsc": "Medición más antigua primero",
+ "alphaAsc": "Nombre (A–Z)",
+ "alphaDesc": "Nombre (Z–A)",
"manual": "Ordenar manualmente",
"hint": "Con el orden manual puedes arrastrar los biomarcadores de abajo en tu propio orden."
},
@@ -6513,7 +6517,15 @@
"biomarkerPlaceholder": "Elige un biomarcador",
"defineNew": "+ Definir nuevo biomarcador",
"noBiomarkersHint": "Define primero un biomarcador para registrar un valor.",
- "pickBiomarkerError": "Elige primero un biomarcador."
+ "pickBiomarkerError": "Elige primero un biomarcador.",
+ "resultType": "Tipo de resultado",
+ "numeric": "Numérico",
+ "qualitative": "Cualitativo",
+ "qualitativeResult": "Resultado",
+ "qualitativePlaceholder": "p. ej. negativo",
+ "qualNegative": "negativo",
+ "qualPositive": "positivo",
+ "qualBorderline": "límite"
},
"backToLabs": "Volver a los resultados",
"hasNote": "Nota",
@@ -6625,7 +6637,44 @@
}
},
"noteLoadFailed": "No se pudo cargar la nota; se mantendrá sin cambios al guardar.",
- "showingLatestOf": "Mostrando los {shown} resultados más recientes de {total}"
+ "showingLatestOf": "Mostrando los {shown} resultados más recientes de {total}",
+ "ocr": {
+ "scanButton": "Escanear un informe",
+ "uploadHint": "Sube una foto o un PDF de un informe de laboratorio",
+ "acceptWithPdf": "JPEG, PNG, WebP o PDF",
+ "acceptImageOnly": "JPEG, PNG o WebP",
+ "extracting": "Leyendo tu informe…",
+ "reviewTitle": "Revisar antes de guardar",
+ "reviewHint": "Revisa cada valor y guarda los que quieras conservar.",
+ "foundCount": "{count} valores encontrados",
+ "confirmRow": "Incluir este valor",
+ "analyteLabel": "Nombre de la prueba",
+ "resultLabel": "Resultado",
+ "valueLabel": "Valor",
+ "unitLabel": "Unidad",
+ "dateLabel": "Fecha",
+ "refLowLabel": "Referencia inferior",
+ "refHighLabel": "Referencia superior",
+ "newBiomarker": "Nuevo biomarcador — se creará",
+ "linksExisting": "Se vincula a {name} existente",
+ "duplicateWarning": "Ya existe un valor coincidente",
+ "lowConfidence": "Verifica este valor",
+ "valueUnreadable": "Valor ilegible — introdúcelo manualmente",
+ "saveSelected": "Guardar {count} seleccionados",
+ "discardAll": "Descartar todo",
+ "savedToast": "{count} resultados de laboratorio guardados",
+ "noRows": "No se encontraron valores en este informe.",
+ "nothingToSave": "Selecciona al menos un valor para guardar.",
+ "saveFailed": "No se pudieron guardar los valores. Inténtalo de nuevo.",
+ "providerUnsupported": "Tu proveedor de IA no puede leer imágenes. Configura un modelo con visión.",
+ "consentRequired": "Se requiere consentimiento de procesamiento de IA para escanear un informe.",
+ "rateLimited": "Demasiados escaneos. Inténtalo más tarde.",
+ "budgetExceeded": "Has alcanzado tu presupuesto de uso de IA de hoy.",
+ "fileTooLarge": "El archivo es demasiado grande (máx. 12 MB).",
+ "fileType": "Sube un JPEG, PNG, WebP o PDF.",
+ "extractFailed": "No se pudo leer el informe. Prueba con una foto más nítida.",
+ "pdfNeedsAnthropic": "El escaneo de PDF necesita un proveedor de visión de Claude; sube una foto en su lugar."
+ }
},
"modules": {
"cycle": {
@@ -6684,6 +6733,10 @@
"illness": {
"title": "Diario de enfermedad",
"subtitle": "Un registro retrospectivo de tus episodios de enfermedad y dolencias.",
+ "settings": {
+ "viewDescription": "Elige si los episodios se muestran como tarjetas o como lista compacta.",
+ "orderDescription": "Arrastra tus episodios al orden en que quieres verlos."
+ },
"customize": "Personalizar diario de enfermedad",
"newEpisode": "Nuevo episodio",
"logDay": "Registrar un día",
@@ -6784,7 +6837,8 @@
"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.",
- "none": "Sin episodios registrados en este período."
+ "none": "Sin episodios registrados en este período.",
+ "analyze": "Analizar la brecha de recuperación"
},
"section": {
"active": "Activos",
diff --git a/messages/fr.json b/messages/fr.json
index b79b3ce16..71ec8f395 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -2010,7 +2010,8 @@
"loadingLabel": "Préparation du briefing",
"noProviderTitle": "Aucun fournisseur d'IA connecté",
"noProviderDescription": "Le briefing quotidien résume tes mesures en langage clair. HealthLog a besoin d'un fournisseur d'IA pour cela : utilise ta propre clé (OpenAI, Anthropic) ou un modèle local. Tes données restent chez toi.",
- "noProviderAction": "Ouvrir les réglages IA"
+ "noProviderAction": "Ouvrir les réglages IA",
+ "staleNoProviderHint": "Ce briefing ne peut pas être actualisé — aucun fournisseur d'IA n'est connecté."
},
"trendsRow": {
"title": "Tendances",
@@ -2268,7 +2269,15 @@
"dictate": "Dicter",
"dictateStop": "Arrêter la dictée",
"showConversations": "Afficher les conversations",
- "hideConversations": "Masquer les conversations"
+ "hideConversations": "Masquer les conversations",
+ "heroGreeting": "Comment puis-je t'aider ?",
+ "heroSubline": "Pose une question sur tes tendances, médicaments ou mesures.",
+ "thinkingElapsed": "{seconds}s",
+ "thinkingDuration": "A réfléchi pendant {seconds}s",
+ "thinkingExpandAria": "Montrer ce que le Coach a pris en compte",
+ "thinkingDetail": "Réponse établie à partir de ton aperçu santé.",
+ "tokensUsed": "{count} jetons",
+ "tokensUsedWithModel": "{count} jetons · {model}"
},
"relativeJustNow": "à l’instant",
"relativeMinutesAgoOne": "il y a {count} minute",
@@ -6451,14 +6460,7 @@
"empty": "Aucun rappel de prévention à venir.",
"openLink": "Ouvrir la prévention"
},
- "showMeasurements": "Afficher les mesures",
- "trendStrip": {
- "label": "7 derniers jours",
- "summary": "Dernière valeur {value}, {direction} sur 7 jours.",
- "rising": "en hausse",
- "falling": "en baisse",
- "steady": "stable"
- }
+ "showMeasurements": "Afficher les mesures"
},
"moduleList": {
"viewToggleLabel": "Affichage",
@@ -6492,6 +6494,8 @@
"label": "Ordre des valeurs",
"recentDesc": "Mesure la plus récente d'abord",
"recentAsc": "Mesure la plus ancienne d'abord",
+ "alphaAsc": "Nom (A–Z)",
+ "alphaDesc": "Nom (Z–A)",
"manual": "Trier manuellement",
"hint": "En tri manuel, vous pouvez glisser les biomarqueurs ci-dessous dans votre propre ordre."
},
@@ -6513,7 +6517,15 @@
"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."
+ "pickBiomarkerError": "Choisissez d'abord un biomarqueur.",
+ "resultType": "Type de résultat",
+ "numeric": "Numérique",
+ "qualitative": "Qualitatif",
+ "qualitativeResult": "Résultat",
+ "qualitativePlaceholder": "p. ex. négatif",
+ "qualNegative": "négatif",
+ "qualPositive": "positif",
+ "qualBorderline": "limite"
},
"backToLabs": "Retour aux résultats",
"hasNote": "Note",
@@ -6625,7 +6637,44 @@
}
},
"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}"
+ "showingLatestOf": "Affichage des {shown} derniers résultats sur {total}",
+ "ocr": {
+ "scanButton": "Scanner un compte rendu",
+ "uploadHint": "Importez une photo ou un PDF d'un compte rendu de laboratoire",
+ "acceptWithPdf": "JPEG, PNG, WebP ou PDF",
+ "acceptImageOnly": "JPEG, PNG ou WebP",
+ "extracting": "Lecture de votre compte rendu…",
+ "reviewTitle": "Vérifier avant d'enregistrer",
+ "reviewHint": "Vérifiez chaque valeur, puis enregistrez celles à conserver.",
+ "foundCount": "{count} valeurs trouvées",
+ "confirmRow": "Inclure cette valeur",
+ "analyteLabel": "Nom de l'analyse",
+ "resultLabel": "Résultat",
+ "valueLabel": "Valeur",
+ "unitLabel": "Unité",
+ "dateLabel": "Date",
+ "refLowLabel": "Référence basse",
+ "refHighLabel": "Référence haute",
+ "newBiomarker": "Nouveau biomarqueur — sera créé",
+ "linksExisting": "Lié au {name} existant",
+ "duplicateWarning": "Une valeur correspondante existe déjà",
+ "lowConfidence": "Veuillez vérifier cette valeur",
+ "valueUnreadable": "Valeur illisible — saisissez-la manuellement",
+ "saveSelected": "Enregistrer {count} sélectionnées",
+ "discardAll": "Tout abandonner",
+ "savedToast": "{count} résultats de laboratoire enregistrés",
+ "noRows": "Aucune valeur n'a pu être trouvée dans ce compte rendu.",
+ "nothingToSave": "Sélectionnez au moins une valeur à enregistrer.",
+ "saveFailed": "Impossible d'enregistrer les valeurs. Veuillez réessayer.",
+ "providerUnsupported": "Votre fournisseur d'IA ne peut pas lire les images. Configurez un modèle capable de vision.",
+ "consentRequired": "Le consentement au traitement par IA est requis pour scanner un compte rendu.",
+ "rateLimited": "Trop de scans. Réessayez plus tard.",
+ "budgetExceeded": "Votre budget d'utilisation de l'IA pour aujourd'hui est atteint.",
+ "fileTooLarge": "Le fichier est trop volumineux (max. 12 Mo).",
+ "fileType": "Importez un JPEG, PNG, WebP ou PDF.",
+ "extractFailed": "Impossible de lire le compte rendu. Essayez une photo plus nette.",
+ "pdfNeedsAnthropic": "Le scan PDF nécessite un fournisseur de vision Claude ; importez plutôt une photo."
+ }
},
"modules": {
"cycle": {
@@ -6684,6 +6733,10 @@
"illness": {
"title": "Journal de maladie",
"subtitle": "Un suivi rétrospectif de vos épisodes de maladie et d'affections.",
+ "settings": {
+ "viewDescription": "Choisis si les épisodes s'affichent en cartes ou en liste compacte.",
+ "orderDescription": "Fais glisser tes épisodes dans l'ordre où tu veux les voir."
+ },
"customize": "Personnaliser le journal de maladie",
"newEpisode": "Nouvel épisode",
"logDay": "Enregistrer un jour",
@@ -6784,7 +6837,8 @@
"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.",
- "none": "Aucun épisode enregistré sur cette période."
+ "none": "Aucun épisode enregistré sur cette période.",
+ "analyze": "Analyser l'écart de récupération"
},
"section": {
"active": "Actifs",
diff --git a/messages/it.json b/messages/it.json
index 09a66cf1a..712b5c259 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -2010,7 +2010,8 @@
"loadingLabel": "Preparazione del briefing",
"noProviderTitle": "Nessun provider IA collegato",
"noProviderDescription": "Il briefing giornaliero riassume le tue misurazioni in linguaggio chiaro. HealthLog ha bisogno di un provider IA per questo: usa la tua chiave (OpenAI, Anthropic) o un modello locale. I tuoi dati restano tuoi.",
- "noProviderAction": "Apri impostazioni IA"
+ "noProviderAction": "Apri impostazioni IA",
+ "staleNoProviderHint": "Questo briefing non può aggiornarsi: nessun provider IA è collegato."
},
"trendsRow": {
"title": "Tendenze",
@@ -2268,7 +2269,15 @@
"dictate": "Detta",
"dictateStop": "Interrompi dettatura",
"showConversations": "Mostra conversazioni",
- "hideConversations": "Nascondi conversazioni"
+ "hideConversations": "Nascondi conversazioni",
+ "heroGreeting": "Come posso aiutarti?",
+ "heroSubline": "Chiedi dei tuoi andamenti, farmaci o misurazioni.",
+ "thinkingElapsed": "{seconds}s",
+ "thinkingDuration": "Ha riflettuto per {seconds}s",
+ "thinkingExpandAria": "Mostra cosa ha considerato il Coach",
+ "thinkingDetail": "Risposta basata sul tuo riepilogo di salute.",
+ "tokensUsed": "{count} token",
+ "tokensUsedWithModel": "{count} token · {model}"
},
"relativeJustNow": "proprio ora",
"relativeMinutesAgoOne": "{count} minuto fa",
@@ -6451,14 +6460,7 @@
"empty": "Nessun promemoria di prevenzione in arrivo.",
"openLink": "Apri prevenzione"
},
- "showMeasurements": "Mostra le misurazioni",
- "trendStrip": {
- "label": "Ultimi 7 giorni",
- "summary": "Ultimo valore {value}, {direction} su 7 giorni.",
- "rising": "in aumento",
- "falling": "in calo",
- "steady": "stabile"
- }
+ "showMeasurements": "Mostra le misurazioni"
},
"moduleList": {
"viewToggleLabel": "Vista",
@@ -6492,6 +6494,8 @@
"label": "Ordine dei valori",
"recentDesc": "Misurazione più recente prima",
"recentAsc": "Misurazione più vecchia prima",
+ "alphaAsc": "Nome (A–Z)",
+ "alphaDesc": "Nome (Z–A)",
"manual": "Ordina manualmente",
"hint": "Con l'ordinamento manuale puoi trascinare i biomarcatori qui sotto nel tuo ordine."
},
@@ -6513,7 +6517,15 @@
"biomarkerPlaceholder": "Scegli un biomarcatore",
"defineNew": "+ Definisci nuovo biomarcatore",
"noBiomarkersHint": "Definisci prima un biomarcatore per registrare un valore.",
- "pickBiomarkerError": "Scegli prima un biomarcatore."
+ "pickBiomarkerError": "Scegli prima un biomarcatore.",
+ "resultType": "Tipo di risultato",
+ "numeric": "Numerico",
+ "qualitative": "Qualitativo",
+ "qualitativeResult": "Risultato",
+ "qualitativePlaceholder": "es. negativo",
+ "qualNegative": "negativo",
+ "qualPositive": "positivo",
+ "qualBorderline": "borderline"
},
"backToLabs": "Torna ai risultati",
"hasNote": "Nota",
@@ -6625,7 +6637,44 @@
}
},
"noteLoadFailed": "Impossibile caricare la nota; resterà invariata al salvataggio.",
- "showingLatestOf": "Visualizzazione degli ultimi {shown} risultati su {total}"
+ "showingLatestOf": "Visualizzazione degli ultimi {shown} risultati su {total}",
+ "ocr": {
+ "scanButton": "Scansiona un referto",
+ "uploadHint": "Carica una foto o un PDF di un referto di laboratorio",
+ "acceptWithPdf": "JPEG, PNG, WebP o PDF",
+ "acceptImageOnly": "JPEG, PNG o WebP",
+ "extracting": "Lettura del referto…",
+ "reviewTitle": "Controlla prima di salvare",
+ "reviewHint": "Controlla ogni valore, poi salva quelli da conservare.",
+ "foundCount": "{count} valori trovati",
+ "confirmRow": "Includi questo valore",
+ "analyteLabel": "Nome dell'esame",
+ "resultLabel": "Risultato",
+ "valueLabel": "Valore",
+ "unitLabel": "Unità",
+ "dateLabel": "Data",
+ "refLowLabel": "Riferimento inferiore",
+ "refHighLabel": "Riferimento superiore",
+ "newBiomarker": "Nuovo biomarcatore — verrà creato",
+ "linksExisting": "Collegato a {name} esistente",
+ "duplicateWarning": "Esiste già un valore corrispondente",
+ "lowConfidence": "Controlla questo valore",
+ "valueUnreadable": "Valore illeggibile — inseriscilo manualmente",
+ "saveSelected": "Salva {count} selezionati",
+ "discardAll": "Scarta tutto",
+ "savedToast": "{count} risultati di laboratorio salvati",
+ "noRows": "Nessun valore trovato in questo referto.",
+ "nothingToSave": "Seleziona almeno un valore da salvare.",
+ "saveFailed": "Impossibile salvare i valori. Riprova.",
+ "providerUnsupported": "Il tuo provider IA non può leggere le immagini. Configura un modello con visione.",
+ "consentRequired": "Per scansionare un referto è richiesto il consenso al trattamento tramite IA.",
+ "rateLimited": "Troppe scansioni. Riprova più tardi.",
+ "budgetExceeded": "Hai raggiunto il budget di utilizzo IA di oggi.",
+ "fileTooLarge": "Il file è troppo grande (max 12 MB).",
+ "fileType": "Carica un JPEG, PNG, WebP o PDF.",
+ "extractFailed": "Impossibile leggere il referto. Prova con una foto più nitida.",
+ "pdfNeedsAnthropic": "La scansione PDF richiede un provider di visione Claude; carica invece una foto."
+ }
},
"modules": {
"cycle": {
@@ -6684,6 +6733,10 @@
"illness": {
"title": "Diario delle malattie",
"subtitle": "Un registro retrospettivo dei tuoi episodi di malattia e disturbi.",
+ "settings": {
+ "viewDescription": "Scegli se gli episodi appaiono come schede o come elenco compatto.",
+ "orderDescription": "Trascina i tuoi episodi nell'ordine in cui vuoi vederli."
+ },
"customize": "Personalizza diario delle malattie",
"newEpisode": "Nuovo episodio",
"logDay": "Registra un giorno",
@@ -6784,7 +6837,8 @@
"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.",
- "none": "Nessun episodio registrato in questo periodo."
+ "none": "Nessun episodio registrato in questo periodo.",
+ "analyze": "Analizza il divario di recupero"
},
"section": {
"active": "Attivi",
diff --git a/messages/pl.json b/messages/pl.json
index 77f4a881d..832a4d5d2 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -2010,7 +2010,8 @@
"loadingLabel": "Przygotowanie briefingu",
"noProviderTitle": "Brak połączonego dostawcy AI",
"noProviderDescription": "Codzienny briefing podsumowuje Twoje pomiary prostym językiem. HealthLog potrzebuje do tego dostawcy AI — użyj własnego klucza (OpenAI, Anthropic) lub modelu lokalnego. Twoje dane pozostają u Ciebie.",
- "noProviderAction": "Otwórz ustawienia AI"
+ "noProviderAction": "Otwórz ustawienia AI",
+ "staleNoProviderHint": "Tego briefingu nie można odświeżyć — nie połączono żadnego dostawcy AI."
},
"trendsRow": {
"title": "Trendy",
@@ -2268,7 +2269,15 @@
"dictate": "Dyktuj",
"dictateStop": "Zatrzymaj dyktowanie",
"showConversations": "Pokaż rozmowy",
- "hideConversations": "Ukryj rozmowy"
+ "hideConversations": "Ukryj rozmowy",
+ "heroGreeting": "W czym mogę pomóc?",
+ "heroSubline": "Zapytaj o swoje trendy, leki lub pomiary.",
+ "thinkingElapsed": "{seconds}s",
+ "thinkingDuration": "Myślał przez {seconds}s",
+ "thinkingExpandAria": "Pokaż, co Coach wziął pod uwagę",
+ "thinkingDetail": "Odpowiedź na podstawie Twojego podsumowania zdrowia.",
+ "tokensUsed": "{count} tokenów",
+ "tokensUsedWithModel": "{count} tokenów · {model}"
},
"relativeJustNow": "przed chwilą",
"relativeMinutesAgoOne": "{count} minutę temu",
@@ -6451,14 +6460,7 @@
"empty": "Brak nadchodzących przypomnień profilaktycznych.",
"openLink": "Otwórz profilaktykę"
},
- "showMeasurements": "Pokaż pomiary",
- "trendStrip": {
- "label": "Ostatnie 7 dni",
- "summary": "Ostatnia wartość {value}, {direction} w ciągu 7 dni.",
- "rising": "rosnący",
- "falling": "malejący",
- "steady": "stabilny"
- }
+ "showMeasurements": "Pokaż pomiary"
},
"moduleList": {
"viewToggleLabel": "Widok",
@@ -6492,6 +6494,8 @@
"label": "Kolejność wartości",
"recentDesc": "Najnowszy pomiar najpierw",
"recentAsc": "Najstarszy pomiar najpierw",
+ "alphaAsc": "Nazwa (A–Z)",
+ "alphaDesc": "Nazwa (Z–A)",
"manual": "Sortuj ręcznie",
"hint": "Przy sortowaniu ręcznym możesz przeciągnąć biomarkery poniżej we własnej kolejności."
},
@@ -6513,7 +6517,15 @@
"biomarkerPlaceholder": "Wybierz biomarker",
"defineNew": "+ Zdefiniuj nowy biomarker",
"noBiomarkersHint": "Najpierw zdefiniuj biomarker, aby zapisać wartość.",
- "pickBiomarkerError": "Najpierw wybierz biomarker."
+ "pickBiomarkerError": "Najpierw wybierz biomarker.",
+ "resultType": "Typ wyniku",
+ "numeric": "Liczbowy",
+ "qualitative": "Jakościowy",
+ "qualitativeResult": "Wynik",
+ "qualitativePlaceholder": "np. ujemny",
+ "qualNegative": "ujemny",
+ "qualPositive": "dodatni",
+ "qualBorderline": "graniczny"
},
"backToLabs": "Wróć do wyników",
"hasNote": "Notatka",
@@ -6625,7 +6637,44 @@
}
},
"noteLoadFailed": "Nie udało się wczytać notatki; pozostanie niezmieniona po zapisaniu.",
- "showingLatestOf": "Wyświetlanie najnowszych {shown} z {total} wyników"
+ "showingLatestOf": "Wyświetlanie najnowszych {shown} z {total} wyników",
+ "ocr": {
+ "scanButton": "Zeskanuj wynik",
+ "uploadHint": "Prześlij zdjęcie lub PDF wyniku laboratoryjnego",
+ "acceptWithPdf": "JPEG, PNG, WebP lub PDF",
+ "acceptImageOnly": "JPEG, PNG lub WebP",
+ "extracting": "Odczytywanie wyniku…",
+ "reviewTitle": "Sprawdź przed zapisaniem",
+ "reviewHint": "Sprawdź każdy wynik, a następnie zapisz te, które chcesz zachować.",
+ "foundCount": "Znaleziono {count} wyników",
+ "confirmRow": "Uwzględnij ten wynik",
+ "analyteLabel": "Nazwa badania",
+ "resultLabel": "Wynik",
+ "valueLabel": "Wartość",
+ "unitLabel": "Jednostka",
+ "dateLabel": "Data",
+ "refLowLabel": "Dolna granica",
+ "refHighLabel": "Górna granica",
+ "newBiomarker": "Nowy biomarker — zostanie utworzony",
+ "linksExisting": "Łączy z istniejącym {name}",
+ "duplicateWarning": "Pasujący wynik już istnieje",
+ "lowConfidence": "Sprawdź tę wartość",
+ "valueUnreadable": "Wartość nieczytelna — wprowadź ją ręcznie",
+ "saveSelected": "Zapisz {count} wybranych",
+ "discardAll": "Odrzuć wszystko",
+ "savedToast": "Zapisano {count} wyników laboratoryjnych",
+ "noRows": "W tym wyniku nie znaleziono żadnych wartości.",
+ "nothingToSave": "Wybierz co najmniej jeden wynik do zapisania.",
+ "saveFailed": "Nie udało się zapisać wyników. Spróbuj ponownie.",
+ "providerUnsupported": "Twój dostawca AI nie może odczytywać obrazów. Skonfiguruj model z obsługą obrazu.",
+ "consentRequired": "Do zeskanowania wyniku wymagana jest zgoda na przetwarzanie przez AI.",
+ "rateLimited": "Zbyt wiele skanów. Spróbuj ponownie później.",
+ "budgetExceeded": "Osiągnięto dzienny limit użycia AI.",
+ "fileTooLarge": "Plik jest za duży (maks. 12 MB).",
+ "fileType": "Prześlij plik JPEG, PNG, WebP lub PDF.",
+ "extractFailed": "Nie udało się odczytać wyniku. Spróbuj wyraźniejszego zdjęcia.",
+ "pdfNeedsAnthropic": "Skanowanie PDF wymaga dostawcy wizji Claude; prześlij zamiast tego zdjęcie."
+ }
},
"modules": {
"cycle": {
@@ -6684,6 +6733,10 @@
"illness": {
"title": "Dziennik chorób",
"subtitle": "Retrospektywny zapis Twoich epizodów chorób i dolegliwości.",
+ "settings": {
+ "viewDescription": "Wybierz, czy epizody mają być wyświetlane jako karty czy zwarta lista.",
+ "orderDescription": "Przeciągnij epizody w kolejność, w jakiej chcesz je widzieć."
+ },
"customize": "Dostosuj dziennik chorób",
"newEpisode": "Nowy epizod",
"logDay": "Zapisz dzień",
@@ -6784,7 +6837,8 @@
"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.",
- "none": "Brak epizodów zapisanych w tym okresie."
+ "none": "Brak epizodów zapisanych w tym okresie.",
+ "analyze": "Przeanalizuj lukę rekonwalescencji"
},
"section": {
"active": "Aktywne",
diff --git a/package.json b/package.json
index 43357b31a..3108f4568 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "healthlog",
- "version": "1.18.8",
+ "version": "1.18.9",
"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",
diff --git a/prisma/migrations/0181_v1189_qualitative_lab_results/migration.sql b/prisma/migrations/0181_v1189_qualitative_lab_results/migration.sql
new file mode 100644
index 000000000..700ee7365
--- /dev/null
+++ b/prisma/migrations/0181_v1189_qualitative_lab_results/migration.sql
@@ -0,0 +1,14 @@
+-- v1.18.9 — qualitative lab results (positive/negative/borderline serology).
+--
+-- A LabResult was numeric-only (`value` NOT NULL). Qualitative findings
+-- ("Hepatitis Bs-Antigen: negativ") have no number to store. This migration is
+-- ADDITIVE and preserves every existing numeric row:
+--
+-- 1. `value` becomes nullable so a qualitative row can omit it. Existing rows
+-- keep their numeric value untouched (DROP NOT NULL never rewrites data).
+-- 2. `value_text` is added (nullable) to hold the qualitative result string.
+--
+-- A row is QUANTITATIVE when `value IS NOT NULL`, QUALITATIVE when
+-- `value_text IS NOT NULL`. The application layer enforces the exclusive-or.
+ALTER TABLE "lab_results" ALTER COLUMN "value" DROP NOT NULL;
+ALTER TABLE "lab_results" ADD COLUMN "value_text" text;
diff --git a/prisma/migrations/0182_v1189_coach_message_token_usage/migration.sql b/prisma/migrations/0182_v1189_coach_message_token_usage/migration.sql
new file mode 100644
index 000000000..14d9a4c88
--- /dev/null
+++ b/prisma/migrations/0182_v1189_coach_message_token_usage/migration.sql
@@ -0,0 +1,14 @@
+-- v1.18.9 — per-message Coach token usage + model.
+--
+-- The streaming chat endpoint already computes the per-turn token count
+-- (`result.tokensUsed`) and the model that produced the reply, but until
+-- now neither reached the client. Two additive nullable columns let the
+-- quiet per-message token footer survive a conversation reload: the live
+-- turn paints from the `done.usage` SSE frame, persisted history paints
+-- from these columns.
+--
+-- Both NULL by default. No backfill: existing rows (and every user turn
+-- or refusal, which never carries a token count) simply render no footer.
+ALTER TABLE "coach_messages"
+ ADD COLUMN "tokens_used" INTEGER,
+ ADD COLUMN "model" TEXT;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 7cac927df..480c5eaa2 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -3391,7 +3391,14 @@ model LabResult {
panel String?
/// The biomarker name, e.g. "LDL", "HbA1c", "Ferritin".
analyte String
- value Float
+ /// Numeric reading. NULL for a qualitative result (see `valueText`). Exactly
+ /// one of `value` / `valueText` is set per row: `value IS NOT NULL` marks a
+ /// quantitative reading, `valueText IS NOT NULL` a qualitative one.
+ value Float?
+ /// v1.18.9 — qualitative result as free text, e.g. "negativ" / "positiv" /
+ /// "grenzwertig" / "nicht nachweisbar". NULL for a numeric reading. The
+ /// reference bounds + range verdict are meaningless for a qualitative row.
+ valueText String? @map("value_text")
unit String
/// Optional reference-range bounds as reported by the lab.
referenceLow Float? @map("reference_low")
@@ -3636,6 +3643,11 @@ model CoachMessage {
metricSourceJson String? @map("metric_source_json")
providerType String? @map("provider_type")
promptVersion String? @map("prompt_version")
+ // v1.18.9 — per-turn token usage + model, persisted so the quiet
+ // per-message token footer survives a conversation reload. Both
+ // nullable: user turns, refusals, and pre-feature rows carry NULL.
+ tokensUsed Int? @map("tokens_used")
+ model String?
createdAt DateTime @default(now()) @map("created_at")
conversation CoachConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
diff --git a/public/sw.js b/public/sw.js
index dff137972..2f60fa365 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.8";
+ /* @sw-version-fallback */ "v1.18.9";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
diff --git a/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts b/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts
index 44e5e8ce9..856ec16e1 100644
--- a/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts
+++ b/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts
@@ -104,11 +104,11 @@ describe("resolveDashboardFirstPaintGate", () => {
describe("resolveConfiguredTileCount (v1.16.8)", () => {
it("counts only widgets that paint a strip tile, with bp counting double", () => {
- // Default layout: weight, bp, pulse, bodyFat, mood, bpInTarget are
- // tile-visible AND tile-capable; medications + recentWorkouts are
+ // Default layout: weight, bp, pulse, bodyFat, mood, bpInTarget, vo2Max
+ // are tile-visible AND tile-capable; medications + recentWorkouts are
// tile-visible in the stored layout but paint NO strip tile, so
// they must not inflate the silhouette. bp paints sys + dia = 2.
- expect(resolveConfiguredTileCount(DEFAULT_DASHBOARD_LAYOUT)).toBe(7);
+ expect(resolveConfiguredTileCount(DEFAULT_DASHBOARD_LAYOUT)).toBe(8);
});
it("ignores chart-only and iOS-pin-only widget ids", () => {
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 f0b98b953..432602444 100644
--- a/src/app/api/__tests__/module-route-gate-inventory.test.ts
+++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts
@@ -158,6 +158,16 @@ const EXEMPT_ROUTES: ReadonlyArray = [
"src/app/api/labs/restore/route.ts",
"src/app/api/biomarkers/route.ts",
"src/app/api/biomarkers/[id]/route.ts",
+ // v1.18.9 — the Lab-OCR ingestion routes share the LabResult data-layer
+ // reasoning: the labs module gates the SURFACE (the Labs page + scan
+ // affordance), not the row store. The extract route is read-only vision
+ // assistance, the commit route writes the user's own confirmed lab rows,
+ // and the capability probe is an infra availability check carrying no
+ // module data. All three are owner-scoped and AI-gated (consent / budget /
+ // rate); the module toggle hiding the surface does not need to wedge them.
+ "src/app/api/labs/ocr/capability/route.ts",
+ "src/app/api/labs/ocr/extract/route.ts",
+ "src/app/api/labs/ocr/commit/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
diff --git a/src/app/api/illness/insights/route.ts b/src/app/api/illness/insights/route.ts
index 1723f5f94..1e47c0482 100644
--- a/src/app/api/illness/insights/route.ts
+++ b/src/app/api/illness/insights/route.ts
@@ -5,10 +5,18 @@
*
* 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.
+ * authoritative + gated: the "typical gap" is withheld below the signal-
+ * density 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.
+ *
+ * LAZY BY DEFAULT — the recovery-gap is the only expensive part (a bounded
+ * per-episode correlation fan-out). The illness LIST loads with
+ * `includeRecoveryGap` unset, so it pays only for a single count query and
+ * paints fast; the gap is computed solely when the user opens the explicit
+ * "Analyse" expansion (`includeRecoveryGap=true`). With it off the summary
+ * returns the count breakdown and a null typical-gap.
*/
import { NextRequest } from "next/server";
import pLimit from "p-limit";
@@ -40,9 +48,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
const gate = await requireIllnessEnabled(user.id);
if (!gate.enabled) return gate.response;
+ const params = new URL(request.url).searchParams;
const parsed = illnessInsightsQuerySchema.safeParse({
- windowDays:
- new URL(request.url).searchParams.get("windowDays") ?? undefined,
+ windowDays: params.get("windowDays") ?? undefined,
+ includeRecoveryGap: params.get("includeRecoveryGap") ?? undefined,
});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
@@ -51,6 +60,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
}
const windowDays = parsed.data.windowDays ?? 365;
+ const includeRecoveryGap = parsed.data.includeRecoveryGap ?? false;
const now = new Date();
const since = new Date(now.getTime() - windowDays * MS_PER_DAY);
const tz = resolveUserTimezone(user.timezone ?? null);
@@ -67,45 +77,72 @@ export const GET = apiHandler(async (request: NextRequest) => {
},
});
- // 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 limit = pLimit(EPISODE_GAP_CONCURRENCY);
- const gapEntries = await Promise.all(
- gapCandidates.map((r) =>
- limit(async (): Promise<[string, number | null]> => {
- const derived = await computeEpisodeCorrelation(
- user.id,
- {
- id: r.id,
- onsetAt: r.onsetAt,
- resolvedAt: r.resolvedAt,
- lifecycle: r.lifecycle,
+ // The recovery-gap is the only expensive part. LIST loads skip it
+ // entirely (`includeRecoveryGap` off) so the page paints on the single
+ // `findMany` above; the explicit "Analyse" expansion turns it on and pays
+ // the bounded fan-out. Per-episode gap + coverage come from the coverage-
+ // gated engine — a thin episode yields null/low coverage and is filtered
+ // out of the typical-gap median by the aggregate's signal-density gates.
+ const gapById = new Map<
+ string,
+ { recoveryGapDays: number | null; gapMeasurementDays: number }
+ >();
+ if (includeRecoveryGap) {
+ const gapCandidates = rows
+ .filter((r) => r.resolvedAt !== null && r.lifecycle !== "CHRONIC_ONGOING")
+ .slice(0, MAX_GAP_COMPUTATIONS);
+ const limit = pLimit(EPISODE_GAP_CONCURRENCY);
+ const gapEntries = await Promise.all(
+ gapCandidates.map((r) =>
+ limit(
+ async (): Promise<
+ [
+ string,
+ { recoveryGapDays: number | null; gapMeasurementDays: number },
+ ]
+ > => {
+ const derived = await computeEpisodeCorrelation(
+ user.id,
+ {
+ id: r.id,
+ onsetAt: r.onsetAt,
+ resolvedAt: r.resolvedAt,
+ lifecycle: r.lifecycle,
+ },
+ tz,
+ now,
+ );
+ return [
+ r.id,
+ {
+ recoveryGapDays:
+ derived.status === "ok"
+ ? derived.value.recoveryGapDays
+ : null,
+ // `historyDays` = distinct episode days with a banded vital,
+ // present on both the `ok` and `insufficient` arms.
+ gapMeasurementDays: derived.coverage.historyDays,
+ },
+ ];
},
- tz,
- now,
- );
- return [
- r.id,
- derived.status === "ok" ? derived.value.recoveryGapDays : null,
- ];
- }),
- ),
- );
- const gapById = new Map(gapEntries);
+ ),
+ ),
+ );
+ for (const [id, entry] of gapEntries) gapById.set(id, entry);
+ }
- 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 episodes: RetrospectiveEpisode[] = rows.map((r) => {
+ const gap = gapById.get(r.id);
+ return {
+ id: r.id,
+ type: r.type,
+ onsetDay: dayKeyForUserTz(r.onsetAt, tz),
+ resolved: r.resolvedAt !== null,
+ recoveryGapDays: gap?.recoveryGapDays ?? null,
+ gapMeasurementDays: gap?.gapMeasurementDays ?? 0,
+ lifecycle: r.lifecycle,
+ };
+ });
const summary = summarizeIllnessRetrospective(episodes);
@@ -115,6 +152,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
episode_count: summary.episodeCount,
gap_sample_size: summary.gapSampleSize,
typical_gap_days: summary.typicalRecoveryGapDays,
+ include_recovery_gap: includeRecoveryGap,
},
});
diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts
index 88d04749b..ea8907ad7 100644
--- a/src/app/api/insights/chat/route.ts
+++ b/src/app/api/insights/chat/route.ts
@@ -652,6 +652,11 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
metricSource: enrichedProvenance,
providerType: workingProviderType,
promptVersion: PROMPT_VERSION,
+ // v1.18.9 — persist the per-turn token count + model so the quiet
+ // token footer survives a conversation reload. The live turn paints
+ // from the `done.usage` SSE frame below; reloads read these columns.
+ tokensUsed: result.tokensUsed ?? null,
+ model: result.model ?? null,
});
// v1.18.7 — the day's spend was already reconciled against the
@@ -703,11 +708,19 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
encodeFrame({ type: "suggestion", suggestion: surfacedSuggestion }),
);
}
+ // v1.18.9 — additive `usage` envelope on the `done` frame so the client
+ // can paint the quiet per-message token footer the instant the stream
+ // closes. Server-authoritative: the client renders these numbers, never
+ // recomputes them. Older clients drop the unknown key.
controller.enqueue(
encodeFrame({
type: "done",
conversationId: workingConversationId,
messageId: assistantMessage.id,
+ usage: {
+ totalTokens: result.tokensUsed ?? null,
+ model: result.model ?? null,
+ },
}),
);
});
diff --git a/src/app/api/insights/generate/__tests__/route.test.ts b/src/app/api/insights/generate/__tests__/route.test.ts
index da86a255e..ff58846b8 100644
--- a/src/app/api/insights/generate/__tests__/route.test.ts
+++ b/src/app/api/insights/generate/__tests__/route.test.ts
@@ -565,4 +565,50 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
// No warm enqueued → no convergence poll for the client to run.
expect(body.data.revalidating).toBe(false);
});
+
+ // v1.18.9 (#4) — the read path reports provider availability so the
+ // insights surfaces can pair a stale cached briefing's honest age with
+ // a connect-provider hint. The cache is served regardless of provider
+ // state, so `hasProvider: false` is the only honest "can never refresh"
+ // signal the client has.
+ it("reports hasProvider: false alongside a served stale briefing", async () => {
+ vi.mocked(hasUsableStatusProvider).mockResolvedValueOnce(false);
+ const stale = new Date(Date.now() - 48 * 60 * 60 * 1000);
+ const cached = { dailyBriefing: { paragraph: "old", keyFindings: [] } };
+ vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
+ insightsCachedAt: stale,
+ insightsCachedText: JSON.stringify(cached),
+ locale: "en",
+ } as never);
+
+ const res = await (GET as unknown as (req: Request) => Promise)(
+ new Request("http://localhost/api/insights/generate"),
+ );
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ data: { cached: boolean; hasProvider: boolean; revalidating: boolean };
+ };
+ // The stale briefing is still delivered (no provider needed to read).
+ expect(body.data.cached).toBe(true);
+ // But it can never refresh — no warm enqueued, hasProvider flagged.
+ expect(body.data.hasProvider).toBe(false);
+ expect(enqueueForceWarm).not.toHaveBeenCalled();
+ expect(body.data.revalidating).toBe(false);
+ });
+
+ it("reports hasProvider: true on a normal cached read", async () => {
+ vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
+ insightsCachedAt: new Date(),
+ insightsCachedText: JSON.stringify({
+ dailyBriefing: { paragraph: "ok", keyFindings: [] },
+ }),
+ locale: "en",
+ } as never);
+
+ const res = await (GET as unknown as (req: Request) => Promise)(
+ new Request("http://localhost/api/insights/generate"),
+ );
+ const body = (await res.json()) as { data: { hasProvider: boolean } };
+ expect(body.data.hasProvider).toBe(true);
+ });
});
diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts
index 48451e071..686dfed8e 100644
--- a/src/app/api/insights/generate/route.ts
+++ b/src/app/api/insights/generate/route.ts
@@ -257,12 +257,22 @@ export const GET = apiHandler(async (request: NextRequest) => {
const isFresh =
cachedAt !== null && Date.now() - cachedAt.getTime() < BRIEFING_FRESH_MS;
+ // v1.18.9 (#4) — resolve provider availability once, up front. The
+ // read path never blocks on or generates through the provider, but it
+ // must REPORT whether one exists: a provider-less account's cached
+ // briefing freezes at the last successful generation (the pregenerate
+ // cron can't refill it), and the UI presented that stale text as
+ // current. Surfacing `hasProvider: false` lets the insights surfaces
+ // pair the honest "Stand: vor X Tagen" age with a discreet
+ // connect-an-AI-provider hint instead of implying the read is live.
+ const hasProvider = await hasUsableStatusProvider(userId);
+
// Read-only: never block on the provider. Warm out of band only when the
// cached briefing is stale / missing AND a provider is configured (a
// provider-less account costs one cheap chain-resolve and shows the
// empty / connect-AI state instead of a wasted enqueue).
let revalidating = false;
- if (!isFresh && (await hasUsableStatusProvider(userId))) {
+ if (!isFresh && hasProvider) {
// Resolve the locale the caller is actually reading (cookie /
// Accept-Language fall-back when `User.locale` is unset) and narrow
// non-German to English — the same convention the nightly's
@@ -285,7 +295,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
const legacyPayload = isLegacyInsightPayload(cached);
annotate({
action: { name: "insights.generate.read" },
- meta: { cached: true, legacyPayload, revalidating },
+ meta: { cached: true, legacyPayload, revalidating, hasProvider },
});
return apiSuccess({
insights: cached,
@@ -297,6 +307,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
// briefing lands instead of sitting on the stale payload for the
// rest of the session.
revalidating,
+ // v1.18.9 (#4) — false when no AI provider is configured anywhere:
+ // the served briefing can never refresh, so the UI pairs its age
+ // with a connect-provider affordance.
+ hasProvider,
});
} catch {
// Invalid cache row — fall through to the empty payload below. The
@@ -307,13 +321,15 @@ export const GET = apiHandler(async (request: NextRequest) => {
annotate({
action: { name: "insights.generate.read" },
- meta: { cached: false, revalidating },
+ meta: { cached: false, revalidating, hasProvider },
});
return apiSuccess({
insights: null,
cached: false,
legacyPayload: false,
revalidating,
+ // v1.18.9 (#4) — see the cached branch above.
+ hasProvider,
});
});
diff --git a/src/app/api/labs/[id]/route.ts b/src/app/api/labs/[id]/route.ts
index 39bd061e5..ca5307478 100644
--- a/src/app/api/labs/[id]/route.ts
+++ b/src/app/api/labs/[id]/route.ts
@@ -150,6 +150,24 @@ export const PUT = apiHandler(
);
}
+ // v1.18.9 — a row keeps its type. Editing `value` on a qualitative row or
+ // `valueText` on a numeric row would either leave both columns populated or
+ // strand the row in an ambiguous state. Reject the cross-type edit with a
+ // 422 — switch types by deleting and re-adding.
+ const rowIsQualitative = existing.valueText !== null;
+ if (rowIsQualitative && d.value !== undefined) {
+ return apiError(
+ "This is a qualitative reading — edit its result text, not a numeric value",
+ 422,
+ );
+ }
+ if (!rowIsQualitative && d.valueText !== undefined) {
+ return apiError(
+ "This is a numeric reading — edit its value, not a qualitative result",
+ 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 /
@@ -158,6 +176,10 @@ export const PUT = apiHandler(
if (d.panel !== undefined) data.panel = d.panel;
if (d.analyte !== undefined) data.analyte = d.analyte;
if (d.value !== undefined) data.value = d.value;
+ // v1.18.9 — qualitative edit. A row stays single-typed: editing `value` on a
+ // qualitative row (or `valueText` on a numeric row) crosses the row's type
+ // and is rejected below. An edit of the matching field updates in place.
+ if (d.valueText !== undefined) data.valueText = d.valueText;
if (d.unit !== undefined) data.unit = d.unit;
if (d.referenceLow !== undefined) data.referenceLow = d.referenceLow;
if (d.referenceHigh !== undefined) data.referenceHigh = d.referenceHigh;
diff --git a/src/app/api/labs/ocr/capability/route.ts b/src/app/api/labs/ocr/capability/route.ts
new file mode 100644
index 000000000..f9e90e265
--- /dev/null
+++ b/src/app/api/labs/ocr/capability/route.ts
@@ -0,0 +1,31 @@
+/**
+ * v1.18.9 — GET /api/labs/ocr/capability
+ *
+ * Cheap probe (no provider call) the Labs UI uses to decide whether to show
+ * the "Scan a report" affordance. Returns `{ available, reason, pdfSupported }`
+ * for the calling user's configured AI provider. The surface stays dark for
+ * codex-only / text-only-model users.
+ */
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import { apiSuccess } from "@/lib/api-response";
+import { resolveOcrCapability } from "@/lib/labs/ocr-capability";
+import { annotate } from "@/lib/logging/context";
+
+export const dynamic = "force-dynamic";
+
+export const GET = apiHandler(async () => {
+ const { user } = await requireAuth();
+
+ const capability = await resolveOcrCapability(user.id);
+
+ annotate({
+ action: { name: "labs.ocr.capability" },
+ meta: {
+ available: capability.available,
+ reason: capability.reason,
+ pdfSupported: capability.pdfSupported,
+ },
+ });
+
+ return apiSuccess(capability);
+});
diff --git a/src/app/api/labs/ocr/commit/__tests__/route.test.ts b/src/app/api/labs/ocr/commit/__tests__/route.test.ts
new file mode 100644
index 000000000..ed84c475c
--- /dev/null
+++ b/src/app/api/labs/ocr/commit/__tests__/route.test.ts
@@ -0,0 +1,135 @@
+/**
+ * v1.18.9 — POST /api/labs/ocr/commit.
+ *
+ * Focus: dedup correctness. A row that duplicates a live reading is skipped,
+ * and — the regression this guards — two identical rows inside ONE confirmed
+ * document collapse to a single write even though neither is yet visible to a
+ * live query. The in-memory seen-set makes in-batch dedup independent of DB
+ * visibility, rather than relying on the prior row autocommitting first.
+ */
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { NextRequest } from "next/server";
+
+vi.mock("@/lib/db", () => ({
+ prisma: {
+ labResult: {
+ findMany: vi.fn(),
+ create: vi.fn(),
+ },
+ auditLog: { create: vi.fn() },
+ },
+}));
+
+vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
+vi.mock("@/lib/auth/audit", () => ({
+ auditLog: vi.fn().mockResolvedValue(undefined),
+}));
+vi.mock("@/lib/idempotency", () => ({
+ withIdempotency:
+ (fn: (...args: Args) => Promise) =>
+ (...args: Args) =>
+ fn(...args),
+}));
+vi.mock("@/lib/jobs/reminder-satisfy", () => ({
+ enqueueReminderSatisfy: vi.fn().mockResolvedValue(undefined),
+}));
+vi.mock("@/lib/labs/biomarker-store", () => ({
+ resolveOrMintBiomarker: vi.fn(),
+}));
+vi.mock("@/lib/labs/serialise", () => ({
+ serialiseLabResult: (created: { id: string }) => ({ id: created.id }),
+}));
+vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
+vi.mock("@/lib/db-compat", () => ({
+ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
+}));
+vi.mock("next/headers", () => ({
+ headers: vi.fn(async () => ({ get: () => null })),
+ cookies: vi.fn(async () => ({
+ get: () => undefined,
+ set: () => {},
+ delete: () => {},
+ })),
+}));
+
+import { POST } from "../route";
+import { prisma } from "@/lib/db";
+import { getSession } from "@/lib/auth/session";
+import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store";
+import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
+
+const SESSION_OK = {
+ session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
+ user: { id: "user-1", username: "tester", role: "USER" as const },
+};
+
+const TAKEN_AT = "2026-06-10T08:00:00.000Z";
+
+function numericRow(value: number) {
+ return {
+ analyte: "Glucose",
+ value,
+ unit: "mg/dL",
+ takenAt: TAKEN_AT,
+ };
+}
+
+function postReq(body: unknown): NextRequest {
+ return new NextRequest("http://localhost/api/labs/ocr/commit", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ });
+}
+
+beforeEach(() => {
+ vi.resetAllMocks();
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ vi.mocked(enqueueReminderSatisfy).mockResolvedValue(undefined as never);
+ vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
+ // No live reading collides at commit time — the in-batch path is what's
+ // under test, so the DB-visibility check returns empty for every row.
+ vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never);
+ let nextId = 0;
+ vi.mocked(prisma.labResult.create).mockImplementation((() =>
+ Promise.resolve({ id: `lr-${++nextId}` })) as never);
+ vi.mocked(resolveOrMintBiomarker).mockResolvedValue({
+ id: "bm-1",
+ name: "Glucose",
+ unit: "mg/dL",
+ panel: null,
+ lowerBound: null,
+ upperBound: null,
+ } as never);
+});
+
+describe("POST /api/labs/ocr/commit dedup", () => {
+ it("collapses identical in-document rows to a single write", async () => {
+ const res = await POST(postReq({ rows: [numericRow(95), numericRow(95)] }));
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ data: { inserted: unknown[]; skipped: Array<{ reason: string }> };
+ };
+
+ // Only the first row writes; the second is skipped as an in-batch
+ // duplicate even though the first never became visible to a live query.
+ expect(prisma.labResult.create).toHaveBeenCalledTimes(1);
+ expect(body.data.inserted).toHaveLength(1);
+ expect(body.data.skipped).toEqual([
+ { analyte: "Glucose", reason: "duplicate" },
+ ]);
+ });
+
+ it("writes distinct values for the same analyte and day", async () => {
+ const res = await POST(
+ postReq({ rows: [numericRow(95), numericRow(110)] }),
+ );
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ data: { inserted: unknown[]; skipped: unknown[] };
+ };
+ expect(prisma.labResult.create).toHaveBeenCalledTimes(2);
+ expect(body.data.inserted).toHaveLength(2);
+ expect(body.data.skipped).toHaveLength(0);
+ });
+});
diff --git a/src/app/api/labs/ocr/commit/route.ts b/src/app/api/labs/ocr/commit/route.ts
new file mode 100644
index 000000000..1607186c0
--- /dev/null
+++ b/src/app/api/labs/ocr/commit/route.ts
@@ -0,0 +1,188 @@
+/**
+ * v1.18.9 — POST /api/labs/ocr/commit
+ *
+ * Writes ONLY the rows the human confirmed on the review screen. Each row
+ * resolves-or-mints a user-scoped biomarker by `(userId, lower(analyte))` —
+ * exactly like the manual lab-write path — then creates a `LabResult` with
+ * `source: "OCR"`. A row that now duplicates a live reading (re-checked at
+ * commit time) is skipped rather than written. Idempotent (Idempotency-Key).
+ *
+ * `userId` is always narrowed from the session — never a body field. The write
+ * `data` object is built field-by-field (no mass assignment).
+ */
+import { NextRequest } from "next/server";
+
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import {
+ 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 { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
+import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store";
+import { serialiseLabResult } from "@/lib/labs/serialise";
+import { annotate } from "@/lib/logging/context";
+import {
+ ocrCommitSchema,
+ type OcrCommitRow,
+ type OcrSkippedRowDto,
+} from "@/lib/validations/labs-ocr";
+
+export const POST = apiHandler(withIdempotency<[NextRequest]>(commitOcrRows));
+
+/** True when a live reading already records this analyte+day+value. */
+async function isDuplicate(
+ userId: string,
+ row: OcrCommitRow,
+): Promise {
+ const dayStart = new Date(row.takenAt);
+ dayStart.setUTCHours(0, 0, 0, 0);
+ const dayEnd = new Date(row.takenAt);
+ dayEnd.setUTCHours(23, 59, 59, 999);
+
+ const candidates = await prisma.labResult.findMany({
+ where: {
+ userId,
+ deletedAt: null,
+ analyte: { equals: row.analyte.trim(), mode: "insensitive" },
+ takenAt: { gte: dayStart, lte: dayEnd },
+ },
+ select: { value: true, valueText: true },
+ take: 25,
+ });
+ for (const c of candidates) {
+ if (row.value !== undefined && c.value !== null && c.value === row.value) {
+ return true;
+ }
+ if (
+ row.valueText !== undefined &&
+ c.valueText !== null &&
+ c.valueText.trim().toLowerCase() === row.valueText.trim().toLowerCase()
+ ) {
+ return true;
+ }
+ }
+ return false;
+}
+
+/**
+ * Stable in-batch dedup key for a confirmed row: analyte (lower+trim) + UTC day
+ * + the value dimension that `isDuplicate` matches on. Two rows in ONE document
+ * that resolve to the same key are the same reading; only the first is written.
+ * Mirrors the `isDuplicate` match semantics so in-batch dedup no longer depends
+ * on a prior row autocommitting before the next row's live query runs.
+ */
+function inBatchKey(row: OcrCommitRow): string {
+ const analyte = row.analyte.trim().toLowerCase();
+ const dayKey = new Date(row.takenAt).toISOString().slice(0, 10);
+ const valuePart =
+ row.value !== undefined
+ ? `n:${row.value}`
+ : row.valueText !== undefined
+ ? `t:${row.valueText.trim().toLowerCase()}`
+ : "∅";
+ return `${analyte}|${dayKey}|${valuePart}`;
+}
+
+async function commitOcrRows(request: NextRequest) {
+ const { user } = await requireAuth();
+
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 256 * 1024,
+ });
+ if (jsonError) return jsonError;
+
+ const parsed = ocrCommitSchema.safeParse(body);
+ if (!parsed.success) {
+ annotate({
+ action: { name: "labs.ocr.commit.validation-failed" },
+ meta: { issue_count: parsed.error.issues.length },
+ });
+ // Free-text analyte / unit could land in a Zod issue message — strip values.
+ const auditIssues = sanitiseZodIssues(parsed.error.issues, {
+ stripValuesFromMessage: true,
+ });
+ prisma.auditLog
+ .create({
+ data: {
+ userId: user.id,
+ action: "labs.ocr.commit.validation-failed",
+ details: JSON.stringify({ issues: auditIssues }),
+ },
+ })
+ .catch(() => {});
+ return returnAllZodIssues(parsed.error, 422);
+ }
+
+ const inserted: ReturnType[] = [];
+ const skipped: OcrSkippedRowDto[] = [];
+ // Tracks the keys already written in THIS request so an in-document duplicate
+ // is caught even before the prior row is visible to a live query. The
+ // mint-then-create on a mid-row failure can leave an orphan biomarker, which
+ // is benign and self-healing — the manual lab-write path mints the same way.
+ const writtenInBatch = new Set();
+
+ for (const row of parsed.data.rows) {
+ const key = inBatchKey(row);
+ if (writtenInBatch.has(key) || (await isDuplicate(user.id, row))) {
+ skipped.push({ analyte: row.analyte.trim(), reason: "duplicate" });
+ continue;
+ }
+
+ const isQualitative = row.valueText !== undefined;
+ const biomarker = await resolveOrMintBiomarker(user.id, {
+ analyte: row.analyte,
+ // A qualitative reading has no numeric unit / range.
+ unit: isQualitative ? (row.unit ?? "") : (row.unit as string),
+ referenceLow: isQualitative ? null : (row.referenceLow ?? null),
+ referenceHigh: isQualitative ? null : (row.referenceHigh ?? null),
+ panel: row.panel ?? null,
+ });
+
+ // Field-by-field — never spread the parsed row. The row stamps the resolved
+ // catalog name/unit/range as historical truth and keeps the FK.
+ const created = await prisma.labResult.create({
+ data: {
+ userId: user.id,
+ biomarkerId: biomarker.id,
+ panel: biomarker.panel,
+ analyte: biomarker.name,
+ value: row.value ?? null,
+ valueText: row.valueText ?? null,
+ unit: biomarker.unit,
+ referenceLow: biomarker.lowerBound,
+ referenceHigh: biomarker.upperBound,
+ takenAt: row.takenAt,
+ source: "OCR",
+ noteEncrypted: null,
+ },
+ });
+
+ writtenInBatch.add(key);
+ inserted.push(serialiseLabResult(created, biomarker));
+ }
+
+ await auditLog("labs.ocr.commit", {
+ userId: user.id,
+ ipAddress: getClientIp(request),
+ details: { count: inserted.length, skipped: skipped.length },
+ });
+
+ annotate({
+ action: { name: "labs.ocr.committed" },
+ meta: { inserted: inserted.length, skipped: skipped.length },
+ });
+
+ // A lab panel just landed — resolve any "annual blood panel" reminders now
+ // rather than waiting on the cron. Fire-and-forget.
+ if (inserted.length > 0) {
+ void enqueueReminderSatisfy(user.id).catch(() => {});
+ }
+
+ return apiSuccess({ inserted, skipped });
+}
diff --git a/src/app/api/labs/ocr/extract/route.ts b/src/app/api/labs/ocr/extract/route.ts
new file mode 100644
index 000000000..103590232
--- /dev/null
+++ b/src/app/api/labs/ocr/extract/route.ts
@@ -0,0 +1,234 @@
+/**
+ * v1.18.9 — POST /api/labs/ocr/extract
+ *
+ * Read-only (NOT idempotent) vision extraction of a paper lab report. Accepts
+ * a multipart photo / PDF, runs it through the user's vision-capable AI
+ * provider, and returns STRUCTURED proposed rows for the mandatory human
+ * review screen. Nothing is written to the database here; the raw upload lives
+ * in memory only and is never persisted or logged.
+ *
+ * Guards, in order (mirrors the Coach's discipline):
+ * requireAuth → resolve vision provider (422 if none) → assertConsentForChain
+ * → rate-limit (6/h) → reserveBudget → bounded body read → mime sniff →
+ * PDF/provider gate → runOcrExtraction → reconcile budget.
+ *
+ * The extracted text is UNTRUSTED (prompt-injection): the server never acts on
+ * an instruction inside the document — the human review step is the safety
+ * boundary, and the commit route is the only write path.
+ */
+import { Buffer } from "node:buffer";
+
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import { apiError, apiSuccess } from "@/lib/api-response";
+import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
+import { assertConsentForChain } from "@/lib/ai/consent-guard";
+import {
+ buildDateKey,
+ reconcileSpend,
+ reserveBudget,
+} from "@/lib/ai/coach/budget";
+import { resolveVisionProvider } from "@/lib/labs/ocr-capability";
+import { OcrExtractError, runOcrExtraction } from "@/lib/labs/ocr-extract";
+import {
+ BodyTooLargeError,
+ detectOcrMimeType,
+ OCR_MAX_BYTES,
+ readBoundedBody,
+} from "@/lib/labs/ocr-upload";
+import { annotate } from "@/lib/logging/context";
+import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
+
+export const dynamic = "force-dynamic";
+
+/** 6 vision calls per hour — they are expensive. */
+const EXTRACT_LIMIT_PER_HOUR = 6;
+const EXTRACT_WINDOW_MS = 60 * 60 * 1000;
+
+export const POST = apiHandler(async (request: Request) => {
+ const { user } = await requireAuth();
+
+ // 1. Resolve a vision-capable provider. 422 when none is configured.
+ const { chain, pick } = await resolveVisionProvider(user.id);
+ if (!pick) {
+ annotate({
+ action: { name: "labs.ocr.providerUnsupported" },
+ meta: { chainLength: chain.length },
+ });
+ return apiError("No vision-capable AI provider is configured", 422, {
+ errorCode: "labs.ocr.providerUnsupported",
+ });
+ }
+
+ // 2. Consent gate — server-managed egress of health data requires a receipt.
+ await assertConsentForChain({ userId: user.id, chain, surface: "insights" });
+
+ // 3. Rate-limit (vision calls are costly).
+ const rl = await checkRateLimit(
+ `labs-ocr:${user.id}`,
+ EXTRACT_LIMIT_PER_HOUR,
+ EXTRACT_WINDOW_MS,
+ );
+ if (!rl.allowed) {
+ annotate({ action: { name: "labs.ocr.rateLimited" } });
+ const response = apiError("Too many scans. Try again later.", 429, {
+ errorCode: "labs.ocr.rateLimited",
+ });
+ for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
+ response.headers.set(k, v);
+ }
+ return response;
+ }
+
+ // 4. Reserve the day's budget BEFORE the provider call (atomic, TOCTOU-safe).
+ const dateKey = buildDateKey();
+ const reservation = await reserveBudget(
+ user.id,
+ AI_BUDGETS.ocrExtract.maxTokens,
+ dateKey,
+ );
+ if (!reservation.allowed) {
+ annotate({
+ action: { name: "labs.ocr.budget.exceeded" },
+ meta: { totalAfter: reservation.totalAfter },
+ });
+ return apiError("Your AI usage budget for today is reached.", 429, {
+ errorCode: "labs.ocr.budgetExceeded",
+ });
+ }
+
+ // From here on a failure must refund the reservation.
+ try {
+ // 5. Pre-flight on the declared content length, then a stream-level
+ // bounded read (a chunked upload omits Content-Length and would otherwise
+ // buffer the whole body before any size check).
+ const contentLength = Number(request.headers.get("content-length") ?? 0);
+ if (contentLength > OCR_MAX_BYTES) {
+ annotate({
+ action: { name: "labs.ocr.fileRejected" },
+ meta: { reason: "content_length_exceeded" },
+ });
+ await reconcileSpend(user.id, reservation.reserved, 0, dateKey);
+ return apiError("File is too large (max 12 MB).", 413, {
+ errorCode: "labs.ocr.fileTooLarge",
+ });
+ }
+
+ let formData: FormData;
+ try {
+ const bytes = await readBoundedBody(request.body, OCR_MAX_BYTES);
+ formData = await new Response(new Blob([bytes]), {
+ headers: { "content-type": request.headers.get("content-type") ?? "" },
+ }).formData();
+ } catch (err) {
+ await reconcileSpend(user.id, reservation.reserved, 0, dateKey);
+ if (err instanceof BodyTooLargeError) {
+ annotate({
+ action: { name: "labs.ocr.fileRejected" },
+ meta: { reason: "stream_size_exceeded" },
+ });
+ return apiError("File is too large (max 12 MB).", 413, {
+ errorCode: "labs.ocr.fileTooLarge",
+ });
+ }
+ return apiError("Invalid multipart body", 400);
+ }
+
+ const file = formData.get("file");
+ if (!(file instanceof File)) {
+ await reconcileSpend(user.id, reservation.reserved, 0, dateKey);
+ return apiError("Field 'file' must be a file", 422);
+ }
+
+ let buffer: Buffer;
+ try {
+ buffer = Buffer.from(await file.arrayBuffer());
+ } catch {
+ await reconcileSpend(user.id, reservation.reserved, 0, dateKey);
+ return apiError("Failed to read uploaded file", 400);
+ }
+
+ // 6. Magic-byte MIME sniff (the wire Content-Type is untrusted).
+ const mime = detectOcrMimeType(buffer);
+ if (!mime) {
+ annotate({
+ action: { name: "labs.ocr.fileRejected" },
+ meta: { reason: "unsupported_mime" },
+ });
+ await reconcileSpend(user.id, reservation.reserved, 0, dateKey);
+ return apiError("Upload a JPEG, PNG, WebP, or PDF.", 415, {
+ errorCode: "labs.ocr.fileType",
+ });
+ }
+
+ // 7. PDF gate — only Anthropic reads a native document block in v1.
+ if (mime === "application/pdf" && !pick.pdfSupported) {
+ annotate({
+ action: { name: "labs.ocr.fileRejected" },
+ meta: { reason: "pdf_provider_unsupported" },
+ });
+ await reconcileSpend(user.id, reservation.reserved, 0, dateKey);
+ return apiError(
+ "PDF scanning needs a Claude vision provider; upload a photo instead.",
+ 422,
+ { errorCode: "labs.ocr.pdfNeedsAnthropic" },
+ );
+ }
+
+ const dataBase64 = buffer.toString("base64");
+ const images =
+ mime === "application/pdf" ? [] : [{ mediaType: mime, dataBase64 }];
+ const documents =
+ mime === "application/pdf"
+ ? [{ mediaType: "application/pdf" as const, dataBase64 }]
+ : [];
+
+ // 8. Run the extraction. The actual token spend reconciles the reservation.
+ let actualTokens = 0;
+ try {
+ const result = await runOcrExtraction({
+ userId: user.id,
+ provider: pick.entry.instance,
+ providerType: pick.providerType,
+ images,
+ documents,
+ });
+ // The orchestration does not surface token counts; reconcile against the
+ // reserved estimate as the spend ceiling (the provider already billed it).
+ actualTokens = reservation.reserved;
+ await reconcileSpend(
+ user.id,
+ reservation.reserved,
+ actualTokens,
+ dateKey,
+ );
+ return apiSuccess(result);
+ } catch (err) {
+ // Provider/extraction failure — the call may still have burned tokens, so
+ // reconcile against the reserved estimate rather than refunding in full.
+ await reconcileSpend(
+ user.id,
+ reservation.reserved,
+ actualTokens,
+ dateKey,
+ );
+ if (err instanceof OcrExtractError) {
+ return apiError("Couldn't read the report. Try a clearer photo.", 422, {
+ errorCode: "labs.ocr.extractFailed",
+ });
+ }
+ annotate({
+ action: { name: "labs.ocr.extractFailed" },
+ meta: { reason: "provider_error" },
+ });
+ return apiError("Couldn't read the report. Try a clearer photo.", 502, {
+ errorCode: "labs.ocr.extractFailed",
+ });
+ }
+ } catch (err) {
+ // A guard threw after the reservation (e.g. consent races) — refund fully.
+ await reconcileSpend(user.id, reservation.reserved, 0, dateKey).catch(
+ () => {},
+ );
+ throw err;
+ }
+});
diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts
index dce2e5a93..0c4821d02 100644
--- a/src/app/api/labs/route.ts
+++ b/src/app/api/labs/route.ts
@@ -173,6 +173,7 @@ async function postLabResult(request: NextRequest) {
panel,
analyte,
value,
+ valueText,
unit,
referenceLow,
referenceHigh,
@@ -180,6 +181,10 @@ async function postLabResult(request: NextRequest) {
note,
} = parsed.data;
+ // v1.18.9 — a qualitative reading carries `valueText` ("negativ" / …) instead
+ // of a number. The Zod refine guarantees exactly one of the two is present.
+ const isQualitative = valueText !== undefined;
+
// 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
@@ -207,9 +212,12 @@ async function postLabResult(request: NextRequest) {
} else {
biomarker = await resolveOrMintBiomarker(user.id, {
analyte: analyte as string,
- unit: unit as string,
- referenceLow: referenceLow ?? null,
- referenceHigh: referenceHigh ?? null,
+ // A qualitative reading has no numeric unit / range — mint the catalog
+ // marker with an empty unit label and no bounds. A numeric reading
+ // supplies its unit (Zod requires it on the free-text numeric path).
+ unit: isQualitative ? (unit ?? "") : (unit as string),
+ referenceLow: isQualitative ? null : (referenceLow ?? null),
+ referenceHigh: isQualitative ? null : (referenceHigh ?? null),
panel: panel ?? null,
});
}
@@ -224,7 +232,9 @@ async function postLabResult(request: NextRequest) {
biomarkerId: biomarker.id,
panel: biomarker.panel,
analyte: biomarker.name,
- value,
+ // Exactly one of value / valueText is set (the Zod XOR refine).
+ value: value ?? null,
+ valueText: valueText ?? null,
unit: biomarker.unit,
referenceLow: biomarker.lowerBound,
referenceHigh: biomarker.upperBound,
diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts
index 0974fab9b..4cfb40d7d 100644
--- a/src/app/api/measurements/batch/route.ts
+++ b/src/app/api/measurements/batch/route.ts
@@ -648,7 +648,12 @@ async function postBatch(request: NextRequest): Promise {
// and must invalidate every consumer that reads through this user's
// measurements.
if (insertedCount > 0 || updatedCount > 0) {
- invalidateUserMeasurements(user.id);
+ // v1.18.9 (#38) — hard-evict so a focus-refetch after a background
+ // iOS / Apple-Health batch sync returns post-sync data. A mark-stale
+ // would let the `cachedSwr` snapshot serve the pre-batch body on the
+ // very next read, leaving the dashboard up to ~180 s stale; the manual
+ // measurement routes already pass `{ evict: true }` for the same reason.
+ invalidateUserMeasurements(user.id, { evict: true });
// v1.5.0 — refresh the persistent rollup table for every distinct
// (type, day) the batch touched. We use the `prepared` rows
diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx
index 36ec5ed8e..0f3a51e94 100644
--- a/src/app/insights/page.tsx
+++ b/src/app/insights/page.tsx
@@ -426,6 +426,12 @@ export default function InsightsPage() {
// as its own outcome; swap the futile regenerate CTA for a quiet
// Settings → AI hint instead of an eternal "preparing" loop.
noProvider={advisor.readOutcome === "no-provider"}
+ // v1.18.9 (#4) — a (stale) cached briefing is shown but no provider
+ // is connected, so it can never refresh. Pair the relative-age
+ // footer with a discreet connect-provider hint. Only fires when a
+ // briefing is actually rendered (the empty-state path owns the
+ // `noProvider` branch above).
+ noProviderStale={briefingPayload !== null && !advisor.hasProvider}
/>
) : null,
vitals: ,
@@ -480,6 +486,11 @@ export default function InsightsPage() {
// settles false on success AND error, so a no-score account
// collapses the column exactly once.
healthScorePending={analyticsQuery.isPending && !analytics}
+ // v1.18.9 (#4) — the hero subtitle is the cached briefing paragraph;
+ // when it can never refresh (no AI provider) pair the "Generated
+ // " line with a discreet connect-provider hint, matching
+ // the briefing card footer. Only when a briefing is actually shown.
+ noProviderStale={briefingPayload !== null && !advisor.hasProvider}
/>
{/* v1.15.18 — the inline "Anpassen" toggle was removed. Customising the
diff --git a/src/app/insights/sleep/page.tsx b/src/app/insights/sleep/page.tsx
index f8e779af6..101b49d4d 100644
--- a/src/app/insights/sleep/page.tsx
+++ b/src/app/insights/sleep/page.tsx
@@ -117,13 +117,16 @@ export default function InsightsSchlafPage() {
/>
{/*
- v1.17.0 — sleep-debt headline. Server-authoritative (the same DTO iOS
- renders), gated on the data-bearing branch so a source-less account
- never fetches. v1.18.7 W-D — the chronotype moved out to the prominent
- bottom block (`` below); this section is the
- sleep-debt headline alone.
+ v1.17.0 — sleep-debt headline + v1.18.7 W-D chronotype, paired in one
+ shared row. Both read the same server-authoritative `["sleep-rhythm"]`
+ cache (one fetch), so the grid adds no round-trip. They stack on narrow
+ viewports and sit side-by-side from `sm` up; each card keeps its own
+ internals (the debt headline and the chronotype band treatment).
*/}
-
+
+
+
+
{/*
v1.17.1 — "Sleep quality" block. Surfaces the WHOOP / Oura nightly
@@ -137,15 +140,6 @@ export default function InsightsSchlafPage() {
-
- {/*
- v1.18.7 W-D — chronotype as the PROMINENT bottom treatment of the
- Sleep view (maintainer directive): a large band label with the natural
- sleep-midpoint clock beneath it and an expandable disclosure for social
- jetlag + MSFsc. Reads the same `["sleep-rhythm"]` cache the debt section
- warmed, so it adds no round-trip; gated on the data-bearing branch.
- */}
-
);
}
diff --git a/src/app/labs/page.tsx b/src/app/labs/page.tsx
index 6abc781fb..5dd27f3ac 100644
--- a/src/app/labs/page.tsx
+++ b/src/app/labs/page.tsx
@@ -4,10 +4,12 @@ import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
-import { Loader2, Plus, Wrench } from "lucide-react";
+import { Loader2, Plus, ScanLine, Wrench } from "lucide-react";
import { LabForm } from "@/components/labs/lab-form";
import { LabList } from "@/components/labs/lab-list";
+import { OcrReviewDialog } from "@/components/labs/ocr-review-dialog";
+import { useOcrCapability } from "@/components/labs/use-ocr-extract";
import { Button } from "@/components/ui/button";
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
@@ -27,6 +29,10 @@ export default function LabsPage() {
// 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);
+ // v1.18.9 — Lab-OCR "Scan a report" dialog. The scan affordance only shows
+ // when the user's configured AI provider can read images (capability probe).
+ const [scanOpen, setScanOpen] = useState(false);
+ const ocrCapability = useOcrCapability(isAuthenticated);
const refreshVisible = useCallback(
() => queryClient.invalidateQueries({ type: "active" }),
@@ -67,6 +73,19 @@ export default function LabsPage() {
+ {/* v1.18.9 — "Scan a report" Lab-OCR entry. Shown only when the
+ capability probe reports a vision-capable provider is configured,
+ so the surface stays dark for codex-only / text-only-model users. */}
+ {ocrCapability.data?.available ? (
+
+ ) : null}
{/* v1.18.6 (MOD-01) — the wrench is the module's customize entry
point, left of the primary Add and linking to the Labs settings
page (view, sort order, biomarker CRUD + reorder). Mirrors the
@@ -116,6 +135,22 @@ export default function LabsPage() {
/>
+ {ocrCapability.data?.available ? (
+ {
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.labResults(),
+ });
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.biomarkers(),
+ });
+ }}
+ />
+ ) : null}
+
setDialogOpen(true)} />
);
diff --git a/src/components/illness/illness-insights-card.tsx b/src/components/illness/illness-insights-card.tsx
index 28edcea83..15ea9f02f 100644
--- a/src/components/illness/illness-insights-card.tsx
+++ b/src/components/illness/illness-insights-card.tsx
@@ -2,12 +2,18 @@
/**
* 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.
+ * history"): how many episodes in the window, and — on demand — the typical
+ * recovery gap. Retrospective only — a count of the past, never a forecast.
+ *
+ * v1.18.9 — the card paints instantly on a single count query
+ * (`includeRecoveryGap` off). The recovery gap is the only expensive figure
+ * (a bounded per-episode correlation fan-out), so it is computed LAZILY: it
+ * runs only when the user opens the "Analyse" expansion, never on list load.
*/
+import { useState } from "react";
import { History } from "lucide-react";
+import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "@/lib/i18n/context";
@@ -20,7 +26,18 @@ export function IllnessInsightsCard({
windowDays?: number;
}) {
const { t } = useTranslations();
- const { data, isLoading, isError } = useIllnessInsights(windowDays);
+ // Lightweight count read — fires on mount, no recovery-gap fan-out.
+ const counts = useIllnessInsights(windowDays);
+
+ // The heavy recovery-gap read stays disabled until the user opens it.
+ const [analyzeOpen, setAnalyzeOpen] = useState(false);
+ const gap = useIllnessInsights(windowDays, {
+ includeRecoveryGap: true,
+ enabled: analyzeOpen,
+ });
+
+ const data = counts.data;
+ const hasEpisodes = !counts.isError && !!data && data.episodeCount > 0;
return (
@@ -33,35 +50,52 @@ export function IllnessInsightsCard({
- {isLoading ? (
+ {counts.isLoading ? (
);
}
diff --git a/src/components/illness/use-illness.ts b/src/components/illness/use-illness.ts
index 75ecf6866..70be6ef08 100644
--- a/src/components/illness/use-illness.ts
+++ b/src/components/illness/use-illness.ts
@@ -61,13 +61,26 @@ export function useIllnessCorrelation(episodeId: string | null) {
});
}
-/** The cross-episode retrospective summary over a trailing window in days. */
-export function useIllnessInsights(windowDays = 365) {
+/**
+ * The cross-episode retrospective summary over a trailing window in days.
+ *
+ * `includeRecoveryGap` is OFF by default: the count breakdown is served by a
+ * single fast query and the recovery-gap stays null. The illness list mounts
+ * this lightweight read; only the explicit "Analyse" expansion opts into the
+ * expensive per-episode correlation (`includeRecoveryGap: true`), which caches
+ * under its own key. `enabled` lets a caller defer the heavy read until opened.
+ */
+export function useIllnessInsights(
+ windowDays = 365,
+ options: { includeRecoveryGap?: boolean; enabled?: boolean } = {},
+) {
+ const includeRecoveryGap = options.includeRecoveryGap ?? false;
return useQuery({
- queryKey: queryKeys.illnessInsights(windowDays),
+ queryKey: queryKeys.illnessInsights(windowDays, includeRecoveryGap),
+ enabled: options.enabled ?? true,
queryFn: () =>
apiGet(
- `/api/illness/insights?windowDays=${windowDays}`,
+ `/api/illness/insights?windowDays=${windowDays}&includeRecoveryGap=${includeRecoveryGap}`,
),
});
}
diff --git a/src/components/insights/__tests__/layout-coach-fab.test.tsx b/src/components/insights/__tests__/layout-coach-fab.test.tsx
index 02183430a..767dca039 100644
--- a/src/components/insights/__tests__/layout-coach-fab.test.tsx
+++ b/src/components/insights/__tests__/layout-coach-fab.test.tsx
@@ -71,7 +71,7 @@ describe("", () => {
expect(html).not.toContain("lg:bottom-6");
});
- it("paints a white glyph + offset focus ring on the gradient", () => {
+ it("paints the on-accent glyph + offset focus ring on the gradient", () => {
mockPathname = "/";
const html = render(
@@ -79,10 +79,11 @@ describe("", () => {
,
);
// The glyph is decorative (aria-hidden) and the button carries its own
- // accessible name, so the text-contrast rule does not gate it; the
- // white glyph reads as a clean mark on the gradient. The button still
- // sets text-background for any non-glyph text it might hold.
- expect(html).toContain("text-white");
+ // accessible name, so the text-contrast rule does not gate it; the glyph
+ // shares the button's own on-accent token (text-background) rather than a
+ // harsh pure white, so it reads as a softer mark on the gradient.
+ expect(html).not.toContain("text-white");
+ expect(html).toMatch(/lucide-sparkles[^"]*text-background/);
// The offset ring draws a visible halo around the gradient circle and
// provides the non-text UI-component contrast against the page.
expect(html).toContain("focus-visible:ring-offset-2");
diff --git a/src/components/insights/coach-panel/__tests__/coach-hero.test.tsx b/src/components/insights/coach-panel/__tests__/coach-hero.test.tsx
new file mode 100644
index 000000000..98b2dd6af
--- /dev/null
+++ b/src/components/insights/coach-panel/__tests__/coach-hero.test.tsx
@@ -0,0 +1,69 @@
+import { describe, it, expect } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { I18nProvider } from "@/lib/i18n/context";
+import { CoachHero } from "../coach-hero";
+import { splitProseSegments } from "../streamed-prose";
+
+function render(node: React.ReactNode, locale: "en" | "de" = "en") {
+ return renderToStaticMarkup(
+ {node},
+ );
+}
+
+describe("", () => {
+ it("renders the centred greeting, the composer slot, and the chips", () => {
+ const html = render(
+ composer
}
+ prompts={["What should I tell my doctor?", "Is my medication working?"]}
+ onPickPrompt={() => {}}
+ />,
+ );
+ expect(html).toContain('data-slot="coach-hero"');
+ // Greeting copy from insights.coach.heroGreeting.
+ expect(html).toContain("How can I help you?");
+ // The composer is re-parented into the hero, not forked.
+ expect(html).toContain('data-slot="coach-hero-composer"');
+ expect(html).toContain('data-slot="test-composer"');
+ // Both starter-question chips render.
+ const chips = (html.match(/data-slot="coach-hero-chip"/g) ?? []).length;
+ expect(chips).toBe(2);
+ expect(html).toContain("What should I tell my doctor?");
+ expect(html).toContain("Is my medication working?");
+ });
+
+ it("renders the German greeting under the de locale", () => {
+ const html = render(
+ {}} />,
+ "de",
+ );
+ expect(html).toContain("Wie kann ich dir helfen?");
+ });
+
+ it("omits the chip row when no prompts are supplied", () => {
+ const html = render(
+ {}} />,
+ );
+ expect(html).not.toContain('data-slot="coach-hero-chips"');
+ });
+});
+
+describe("splitProseSegments", () => {
+ it("splits prose into word+trailing-space segments", () => {
+ expect(splitProseSegments("Looking at your data")).toEqual([
+ "Looking ",
+ "at ",
+ "your ",
+ "data",
+ ]);
+ });
+
+ it("returns an empty array for empty input", () => {
+ expect(splitProseSegments("")).toEqual([]);
+ });
+
+ it("keeps a single whitespace-free token intact", () => {
+ expect(splitProseSegments("Drafting…")).toEqual(["Drafting…"]);
+ });
+});
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 cae800634..aa20016d2 100644
--- a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx
+++ b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx
@@ -60,6 +60,28 @@ import {
type InterleavedThreadItem,
} from "../message-thread";
import type { CoachConversationDetailDTO } from "@/lib/ai/coach/types";
+import type { CoachStreamingMessage } from "../use-coach";
+
+// v1.18.9 — `CoachStreamingMessage` gained `usage` / `startedAt` /
+// `reasoning` (per-message token footer + thinking disclosure). The
+// fixtures below predate those fields; this builder fills the defaults so
+// each test only spells out the fields it cares about.
+function streaming(
+ partial: Partial,
+): CoachStreamingMessage {
+ return {
+ content: "",
+ metricSource: null,
+ suggestion: null,
+ inProgress: false,
+ messageId: null,
+ errorCode: null,
+ usage: null,
+ startedAt: null,
+ reasoning: "",
+ ...partial,
+ };
+}
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
@@ -82,6 +104,8 @@ const baseConversation: CoachConversationDetailDTO = {
metricSource: null,
providerType: null,
promptVersion: null,
+ tokensUsed: null,
+ model: null,
},
{
id: "m2",
@@ -96,6 +120,8 @@ const baseConversation: CoachConversationDetailDTO = {
},
providerType: "admin-openai",
promptVersion: "4.20.0",
+ tokensUsed: 312,
+ model: "gpt-4o",
},
],
};
@@ -143,14 +169,14 @@ describe("", () => {
const html = render(
,
);
// 1 persisted user + 1 persisted assistant + 1 streaming assistant.
@@ -161,21 +187,26 @@ describe("", () => {
).length;
expect(userBubbles).toBe(1);
expect(assistantBubbles).toBe(2);
- expect(html).toContain("Looking at your data");
+ // v1.18.9 — the live streaming turn renders each word in its own
+ // fade-in (``), so the prose is no longer one
+ // contiguous text node; assert each word is present span-wrapped.
+ for (const word of ["Looking ", "at ", "your ", "data"]) {
+ expect(html).toContain(`>${word}`);
+ }
});
it("shows the streaming-only bubble when no conversation is loaded yet", () => {
const html = render(
,
);
expect(html).toContain("Drafting…");
@@ -186,14 +217,14 @@ describe("", () => {
const html = render(
,
);
expect(html).toContain("Thinking");
@@ -203,14 +234,14 @@ describe("", () => {
const html = render(
,
);
// v1.4.33 IW7 — copy rewritten from "AI provider" to "Insights
@@ -237,6 +268,8 @@ describe("", () => {
metricSource: null,
providerType: "admin-openai",
promptVersion: "4.20.0",
+ tokensUsed: null,
+ model: null,
},
],
};
@@ -244,14 +277,14 @@ describe("", () => {
const html = render(
,
);
// 1 persisted user + 2 persisted assistant + 0 streaming → 2.
@@ -271,14 +304,14 @@ describe("", () => {
const html = render(
,
);
const assistantBubbles = (
@@ -470,14 +503,14 @@ describe("", () => {
content: "Wie ist mein Blutdruck letzte Woche?",
conversationId: null,
}}
- streaming={{
+ streaming={streaming({
content: "",
metricSource: null,
suggestion: null,
inProgress: true,
messageId: null,
errorCode: null,
- }}
+ })}
/>,
);
expect(html).toContain("Wie ist mein Blutdruck letzte Woche?");
@@ -577,14 +610,14 @@ describe("", () => {
const html = render(
,
);
expect(html).toContain("Daily limit reached; resets at 00:00 UTC.");
@@ -598,14 +631,14 @@ describe("", () => {
const html = render(
,
);
expect(html).toContain(
@@ -627,14 +660,14 @@ describe("", () => {
content: "Why was BP higher on Monday?",
conversationId: "conv-1",
}}
- streaming={{
+ streaming={streaming({
content: "",
metricSource: null,
suggestion: null,
inProgress: false,
messageId: null,
errorCode: null,
- }}
+ })}
/>,
);
const userBubbles = (html.match(/data-slot="coach-bubble-user"/g) ?? [])
diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx
index a6f785ab6..f5107c9f0 100644
--- a/src/components/insights/coach-panel/coach-conversation.tsx
+++ b/src/components/insights/coach-panel/coach-conversation.tsx
@@ -13,6 +13,7 @@ import { queryKeys } from "@/lib/query-keys";
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
import { CoachDrawerBody } from "./coach-drawer-body";
+import { CoachHero } from "./coach-hero";
import { CoachInput } from "./coach-input";
import {
GuidedQuestionBubble,
@@ -283,6 +284,53 @@ export function CoachConversation({
}),
);
+ // v1.18.9 — the live composer, lifted so it can mount in the new-chat
+ // hero (centred) OR docked at the bottom of the conversation without
+ // forking any composer logic (dictation, auto-grow, send/stop, the
+ // guided-question placeholder all behave identically in both spots).
+ const composerNode = (
+ handleSubmit(inputValue)}
+ onCancel={send.cancel}
+ disabled={send.isStreaming}
+ isStreaming={send.isStreaming}
+ autoFocusOnOpen={autoFocusComposer}
+ placeholder={
+ guided.phase === "asking"
+ ? t("insights.coach.guided.answerPlaceholder")
+ : undefined
+ }
+ />
+ );
+
+ // v1.18.9 — the centred new-chat hero replaces the cramped empty thread
+ // on the PAGE surface only. It shows exactly when the surface is a fresh,
+ // untouched new chat: no selected conversation, nothing streaming or
+ // optimistic, no error, and none of the guided / adopt / pending-question
+ // affordances in play (those belong in the docked conversation flow). The
+ // first send flips `send.isStreaming` / `send.optimisticUser`, so the hero
+ // unmounts and the docked thread + bottom composer take over.
+ const heroActive =
+ surface === "page" &&
+ currentConversationId === null &&
+ !send.isStreaming &&
+ !send.streaming.content &&
+ !send.streaming.errorCode &&
+ !send.optimisticUser &&
+ guided.phase === "idle" &&
+ pendingQuestions.length === 0 &&
+ !pendingAdopt;
+
+ // The Coach's own starter questions seed the hero chips (NOT generic
+ // "create an image" prompts). Same two openers the suggested-prompt strip
+ // uses elsewhere, resolved here so the hero stays locale-agnostic.
+ const heroPrompts = [
+ t("insights.suggestedPrompts.tellMyDoctor"),
+ t("insights.suggestedPrompts.medicationWorking"),
+ ];
+
return (
+ }
+ onHistoryClick={
+ surface === "drawer" && onRequestFullView
+ ? onRequestFullView
+ : () => setHistoryTrayOpen(true)
+ }
+ onOpenSourcesTray={() => setSourcesTrayOpen(true)}
+ />
+ )}
`).
+ *
+ * The hero does NOT fork composer logic: it RECEIVES the live
+ * `` as the `composer` slot, so dictation, auto-grow, the
+ * send/stop control, and the guided-question placeholder all behave
+ * exactly as in the docked composer. The suggestion chips seed the same
+ * composer via `onPickPrompt`.
+ *
+ * Motion: one orchestrated reveal — sparkle → greeting → composer → chips
+ * stagger in via `motion-safe` `animate-in` with capped delays;
+ * reduced-motion users get the layout instantly.
+ */
+export interface CoachHeroProps {
+ /** The live `` composer, re-parented into the hero. */
+ composer: React.ReactNode;
+ /** Localised starter prompts shown as chips below the composer. */
+ prompts: string[];
+ /** Seeds the composer with a chip's text (the parent submits on pick). */
+ onPickPrompt: (prompt: string) => void;
+}
+
+export function CoachHero({ composer, prompts, onPickPrompt }: CoachHeroProps) {
+ const { t } = useTranslations();
+
+ return (
+
+ {/* Atmospheric backdrop — a faint purple radial glow behind the
+ hero, never busy. Pointer-events-none + aria-hidden so it is
+ purely decorative; it sits behind the content via -z-10. */}
+
+
+
+ {/* Sparkle mark */}
+
+
+
+
+ {/* Greeting — a slightly larger, tighter display treatment of the
+ existing font (weight/tracking/size only, no new font). */}
+
+
+ {t("insights.coach.heroGreeting")}
+
+
+ {t("insights.coach.heroSubline")}
+
+
+
+ {/* Composer — the live , centred. */}
+
+ {composer}
+
+
+ {/* Starter-question chips, seeded with the Coach's own prompts. */}
+ {prompts.length > 0 && (
+
+ {prompts.map((prompt) => (
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx
index 65d26a33b..add50de79 100644
--- a/src/components/insights/coach-panel/message-thread.tsx
+++ b/src/components/insights/coach-panel/message-thread.tsx
@@ -20,12 +20,14 @@ import { cn } from "@/lib/utils";
import { scrollBehaviorForUser } from "@/lib/motion";
import { useTranslations } from "@/lib/i18n/context";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
-import { stripChartTokens } from "@/lib/insights/chart-tokens";
import { useAuth } from "@/hooks/use-auth";
import { ABOUT_ME_FIELD_MAX_CHARS } from "@/lib/validations/about-me";
import { SourceChips } from "./source-chips";
import { ReminderSuggestionCard } from "./reminder-suggestion-card";
+import { StreamedProse } from "./streamed-prose";
+import { ThinkingDisclosure } from "./thinking-disclosure";
+import { MessageTokenFooter } from "./message-token-footer";
import type {
CoachConversationDetailDTO,
CoachOptimisticUserMessage,
@@ -422,6 +424,8 @@ export function MessageThread({
metricSource={m.metricSource}
providerType={m.providerType}
messageId={m.id}
+ tokensUsed={m.tokensUsed}
+ model={m.model}
/>
);
@@ -452,8 +456,10 @@ export function MessageThread({
aria-live="polite"
aria-relevant="additions text"
// v1.18.7 — a soft fade/slide-in on the assistant turn so the
- // hand-off from the "Thinking…" beat to the first streamed
- // tokens reads as one continuous motion, not a hard swap.
+ // hand-off from the thinking beat to the first streamed tokens
+ // reads as one continuous motion, not a hard swap. v1.18.9 — the
+ // per-word fade now lives in ; this stays a light
+ // container fade for the disclosure → prose transition.
className="motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-1 motion-safe:duration-300"
>
)}
@@ -495,6 +507,35 @@ interface ChatBubbleProps {
* user can't rate before the message lands on disk.
*/
messageId?: string;
+ /**
+ * v1.18.9 — true for the live streaming assistant turn. Drives the
+ * word-by-word prose fade (``); persisted bubbles render
+ * settled plain text.
+ */
+ streaming?: boolean;
+ /**
+ * v1.18.9 — send-anchored timestamp (epoch ms) for the streaming turn,
+ * feeding the thinking disclosure's elapsed timer. Null on persisted
+ * bubbles.
+ */
+ startedAt?: number | null;
+ /**
+ * v1.18.9 — reasoning-summary text from the additive `reasoning` SSE
+ * frame, shown inside the thinking disclosure when present.
+ */
+ reasoning?: string;
+ /**
+ * v1.18.9 — per-turn token usage for the just-finished streaming bubble
+ * (from `done.usage`). Persisted bubbles read `tokensUsed` / `model`
+ * instead.
+ */
+ usage?: import("./use-coach").CoachUsage | null;
+ /**
+ * v1.18.9 — persisted per-message token count + model, for the quiet
+ * token footer on reload. Null on user turns, refusals, and older rows.
+ */
+ tokensUsed?: number | null;
+ model?: string | null;
}
function ChatBubble({
@@ -506,6 +547,12 @@ function ChatBubble({
inProgress,
errorCode,
messageId,
+ streaming,
+ startedAt,
+ reasoning,
+ usage,
+ tokensUsed,
+ model,
}: ChatBubbleProps) {
const { t } = useTranslations();
const { user } = useAuth();
@@ -631,38 +678,53 @@ function ChatBubble({
)}
-
- {/* v1.4.25 W5b — strip stray Metric/enum leak tokens from
- the assistant prose before it lands in the bubble. The
- raw `content` is the streamed Coach reply (or the
- persisted twin after `done` fires); both paths are AI-
- authored so they share the same leak surface as the
- insight prose elsewhere.
-
- v1.16.1 — before the first token lands, the bubble shows
- a classic typing indicator (three pulsing dots) instead
- of the old whole-bubble pulse + "Thinking…" prose. The
- prose stays as the screen-reader announcement. */}
- {/* v1.16.4 — a failed turn with no streamed prose renders the
- error copy INSIDE the bubble instead of an empty bubble
- with a caption below it. With partial prose the prose
- keeps the bubble and the error stays a caption. */}
- {content ? (
- stripChartTokens(content)
- ) : inProgress ? (
-
- ) : safeError ? (
- {safeError}
- ) : (
- ""
- )}
-
+ {/* v1.18.9 — the single thinking indicator (live elapsed-time line,
+ then an auto-collapsed past-tense disclosure). Renders only for
+ the live streaming turn — persisted bubbles pass no `startedAt`,
+ so the disclosure returns null and history stays settled. This
+ replaces the old dots-plus-word combo: there is
+ never a three-dots indicator AND a "Thinking" word at once. */}
+ {streaming && startedAt != null && (
+
+ )}
+ {/* The prose bubble. Skipped while the live turn is still thinking
+ with no prose (the disclosure carries the live state above), so
+ the user never sees an empty bubble under the indicator. The
+ `` fallback below only fires when there is no live
+ disclosure (e.g. a defensive null `startedAt`). */}
+ {(content ||
+ safeError ||
+ (inProgress && !(streaming && startedAt != null))) && (
+
+ {/* v1.4.25 W5b — strip stray Metric/enum leak tokens from the
+ assistant prose. v1.18.9 — the live turn streams in
+ word-by-word with a soft fade (); a settled
+ / persisted turn renders as plain text. */}
+ {/* v1.16.4 — a failed turn with no streamed prose renders the
+ error copy INSIDE the bubble; with partial prose the prose
+ keeps the bubble and the error stays a caption. */}
+ {content ? (
+
+ ) : inProgress ? (
+
+ ) : safeError ? (
+ {safeError}
+ ) : (
+ ""
+ )}
+
+ )}
{safeError && content && (
{safeError}
)}
@@ -784,6 +846,16 @@ function ChatBubble({
const sug = suggestion ?? metricSource?.suggestion ?? null;
return sug ? : null;
})()}
+ {/* v1.18.9 — quiet per-message token footer. The just-finished
+ streaming turn reads the `done.usage` envelope; a persisted /
+ reloaded turn reads the message's own `tokensUsed` + `model`.
+ Skipped on in-flight, errored, and refusal turns. */}
+ {!inProgress && !errorCode && providerType !== "refusal" && (
+
+ )}
{/* v1.4.23 H7 — per-message thumbs feedback. Only persisted
assistant messages get the row (skipped for refusals,
errors, in-flight stream bubbles). The aggregator buckets
diff --git a/src/components/insights/coach-panel/message-token-footer.tsx b/src/components/insights/coach-panel/message-token-footer.tsx
new file mode 100644
index 000000000..f6b17fe6c
--- /dev/null
+++ b/src/components/insights/coach-panel/message-token-footer.tsx
@@ -0,0 +1,37 @@
+"use client";
+
+import { useTranslations } from "@/lib/i18n/context";
+
+/**
+ * v1.18.9 — quiet per-message token-usage caption under an assistant
+ * bubble. Server-authoritative: it renders the count the server reported
+ * (`done.usage.totalTokens` for the live turn, `CoachMessageDTO.tokensUsed`
+ * on reload), never a client-side recomputation. No cost — BYOK users pay
+ * the provider directly and a price table would only drift.
+ *
+ * Renders nothing when there is no count (user turns, refusals, in-flight
+ * bubbles, and pre-feature persisted rows all carry null).
+ */
+export interface MessageTokenFooterProps {
+ tokens: number | null | undefined;
+ /** Provider model name (e.g. `gpt-4o`); appended when present. */
+ model?: string | null;
+}
+
+export function MessageTokenFooter({ tokens, model }: MessageTokenFooterProps) {
+ const { t } = useTranslations();
+ if (tokens == null) return null;
+
+ const label = model
+ ? t("insights.coach.tokensUsedWithModel", { count: tokens, model })
+ : t("insights.coach.tokensUsed", { count: tokens });
+
+ return (
+
+ {label}
+
+ );
+}
diff --git a/src/components/insights/coach-panel/streamed-prose.tsx b/src/components/insights/coach-panel/streamed-prose.tsx
new file mode 100644
index 000000000..370d70a43
--- /dev/null
+++ b/src/components/insights/coach-panel/streamed-prose.tsx
@@ -0,0 +1,96 @@
+"use client";
+
+import { useMemo } from "react";
+
+import { cn } from "@/lib/utils";
+import { stripChartTokens } from "@/lib/insights/chart-tokens";
+
+/**
+ * v1.18.9 — Claude-style word-by-word fade-in for the LIVE Coach turn.
+ *
+ * The provider clients return the whole reply in one shot; the chat route
+ * re-chunks it into per-word `token` SSE frames (`tokeniseForStreaming`),
+ * so `content` grows as frames land. This component renders each
+ * whitespace segment as its own `` that fades + drifts up on mount,
+ * giving the reply a "thinking out loud" cadence rather than a hard dump
+ * of word groups. Once the turn settles (`streaming === false`) the spans
+ * collapse to a single plain-text node — zero residual animation DOM, and
+ * the feedback / evidence layout underneath stays stable.
+ *
+ * Why no "only animate the new words" bookkeeping: each segment is keyed
+ * by its index, so when a fresh SSE frame appends words, React keeps the
+ * already-mounted spans (their CSS entrance already ran and does not
+ * replay on reconcile) and only MOUNTS the new trailing spans — which is
+ * exactly when the `animate-in` entrance fires. The browser does the
+ * "new words only" gating for us; no render-time ref is needed.
+ *
+ * CSP-safe: the entrance is Tailwind's `animate-in fade-in-0
+ * slide-in-from-bottom-1` (compiled utilities served from `'self'`) plus a
+ * per-span inline `animationDelay` / `animationDuration`. Inline `style`
+ * attributes are already permitted under the strict CSP
+ * (`style-src 'self' 'unsafe-inline'`); there is no runtime-injected
+ * `