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 ? (
- ) : isError || !data ? ( -

- {t("illness.insights.none")} -

- ) : data.episodeCount === 0 ? ( + ) : !hasEpisodes ? (

{t("illness.insights.none")}

) : ( -
+

- {data.episodeCount === 1 + {data!.episodeCount === 1 ? t("illness.insights.countOne") : t("illness.insights.countOther", { - count: data.episodeCount, + count: data!.episodeCount, })}

-

- {data.typicalRecoveryGapDays !== null - ? t("illness.insights.typicalGap", { - days: data.typicalRecoveryGapDays, - }) - : t("illness.insights.typicalGapLearning")} -

+ + {/* The recovery gap is computed only when the user asks for it. */} + {!analyzeOpen ? ( + + ) : gap.isLoading ? ( +
+ +
+ ) : gap.isError || !gap.data ? ( +

+ {t("illness.insights.typicalGapLearning")} +

+ ) : ( +

+ {gap.data.typicalRecoveryGapDays !== null + ? t("illness.insights.typicalGap", { + days: gap.data.typicalRecoveryGapDays, + }) + : t("illness.insights.typicalGapLearning")} +

+ )}
)} diff --git a/src/components/illness/illness-settings.tsx b/src/components/illness/illness-settings.tsx index 7b33496f3..d6952bc5e 100644 --- a/src/components/illness/illness-settings.tsx +++ b/src/components/illness/illness-settings.tsx @@ -6,8 +6,18 @@ * Reached from the wrench beside the page's "hinzufügen" button. Lets the * user reorder conditions/episodes + pick the card-vs-list page view. Order * + view persist client-side via `useModuleListPrefs("illness")`. + * + * v1.18.9 — restructured onto the canonical Settings pattern: each block is a + * `` with a `` (neutral icon + `text-lg` + * title + a short muted description), matching the Stimmung / Medikamente + * sections rather than the bespoke bare-`
` headings it carried + * before. */ +import { Eye, ListOrdered } from "lucide-react"; + import { Skeleton } from "@/components/ui/skeleton"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { ModuleViewToggle } from "@/components/module-list/module-view-toggle"; import { @@ -37,23 +47,22 @@ export function IllnessSettings() { })); return ( -
-
-
-

- {t("moduleList.viewHeading")} -

- -
-

- {t("moduleList.viewDescription")} -

-
+
+ + } + /> + -
-

- {t("moduleList.reorder.heading")} -

+ + {isLoading ? (
{Array.from({ length: 3 }, (_, i) => ( @@ -63,7 +72,7 @@ export function IllnessSettings() { ) : ( )} -
+
); } 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 (
- setHistoryRailOpen((open) => !open) - : undefined - } - historyRail={ - surface === "page" ? ( - ` band already renders the - // "Conversations"

; suppress the rail's own

so the - // heading is not stacked twice on the page surface. - hideHeading - onSelect={(id) => { - // v1.16.5 — switching conversations drops the guided - // session; unanswered questions stay pending. - setCurrentConversationId(id); - setPendingAdopt(null); - dispatchGuided({ type: "RESET" }); - }} + {heroActive ? ( + handleSubmit(prompt)} + /> + ) : ( + setHistoryRailOpen((open) => !open) + : undefined + } + historyRail={ + surface === "page" ? ( + ` band already renders the + // "Conversations"

; suppress the rail's own

so the + // heading is not stacked twice on the page surface. + hideHeading + onSelect={(id) => { + // v1.16.5 — switching conversations drops the guided + // session; unanswered questions stay pending. + setCurrentConversationId(id); + setPendingAdopt(null); + dispatchGuided({ type: "RESET" }); + }} + /> + ) : undefined + } + thread={ + - ) : undefined - } - thread={ - - } - composer={ -
- {/* v1.16.4 — quiet adopt-into-self-context offer once a + } + composer={ +
+ {/* v1.16.4 — quiet adopt-into-self-context offer once a clarifying question has been answered. Self-removes after settle; v1.16.5 reports the outcome back into the guided machine for the closing summary. */} - {pendingAdopt && !send.isStreaming ? ( - setPendingAdopt(null)} - onSettled={(adoption) => - dispatchGuided({ - type: "ADOPTION_SETTLED", - index: pendingAdopt.guidedIndex, - adoption, - }) - } - /> - ) : null} - {/* v1.16.5 — guided clarifying-questions entry card (V2 of + {pendingAdopt && !send.isStreaming ? ( + setPendingAdopt(null)} + onSettled={(adoption) => + dispatchGuided({ + type: "ADOPTION_SETTLED", + index: pendingAdopt.guidedIndex, + adoption, + }) + } + /> + ) : null} + {/* v1.16.5 — guided clarifying-questions entry card (V2 of the v1.16.0 chips). Offers the in-chat sequence while questions pend and the flow hasn't started. */} - {guided.phase === "idle" && pendingQuestions.length > 0 ? ( - - dispatchGuided({ type: "START", questions: pendingQuestions }) - } - onLater={() => dispatchGuided({ type: "LATER" })} - onDismissAll={() => - dismissQuestions.mutate(undefined, { - onSuccess: () => dispatchGuided({ type: "LATER" }), - }) - } - /> - ) : null} - handleSubmit(inputValue)} - onCancel={send.cancel} - disabled={send.isStreaming} - isStreaming={send.isStreaming} - autoFocusOnOpen={autoFocusComposer} - placeholder={ - guided.phase === "asking" - ? t("insights.coach.guided.answerPlaceholder") - : undefined - } - /> -
- } - onHistoryClick={ - surface === "drawer" && onRequestFullView - ? onRequestFullView - : () => setHistoryTrayOpen(true) - } - onOpenSourcesTray={() => setSourcesTrayOpen(true)} - /> + {guided.phase === "idle" && pendingQuestions.length > 0 ? ( + + dispatchGuided({ + type: "START", + questions: pendingQuestions, + }) + } + onLater={() => dispatchGuided({ type: "LATER" })} + onDismissAll={() => + dismissQuestions.mutate(undefined, { + onSuccess: () => dispatchGuided({ type: "LATER" }), + }) + } + /> + ) : null} + {composerNode} +
+ } + 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. */} + + ); +} 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 + * `