diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2fa30b3d1..19a318584 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,45 @@
## [Unreleased]
+## [1.18.10] — 2026-06-20 — data-loss fix, measurement consolidation, grounding, and the full-page Coach
+
+A correctness-and-polish release. It closes a silent data-loss path and a stalled measurement-consolidation job, hardens AI output grounding, and acts on a broad round of UI feedback. Two additive migrations (a covering index and a labs setting); no breaking changes.
+
+### Fixed
+
+- Editing one field of a cycle or condition-journal day entry no longer wipes an encrypted note that cannot be decrypted with the current key — the stored value is preserved untouched unless that field is explicitly changed.
+- The nightly consolidation of cumulative metrics (walking/running distance, flights climbed, active energy) no longer aborts on the first conflicting day, so raw per-sample history collapses to daily values as intended. A one-time compaction of the existing backlog runs after deploy.
+- The daily briefing refreshes when regenerated, and the home screen reflects a saved tile layout without a manual reload.
+- The Coach dictation button reports when speech input is unavailable instead of doing nothing.
+
+### Added
+
+- The Coach has a full-width page (no panel-within-a-panel), reachable conversation history, and a clean new-chat start.
+- Scan a lab report without a vision model: optional on-device OCR (off by default) reads the image in the browser and sends only the extracted text to the configured AI provider, then the same mandatory per-row review applies. The labs "Add" action now offers "scan a document" or "add a value manually".
+- Withings sleep now records heart rate, respiratory rate, blood oxygen, sleep HRV, and the sleep score (previously discarded at sync).
+- Oura VO2max is recorded, and the VO2max tile is shown on the dashboard by default.
+
+### Changed
+
+- Preventive-care cards show the cadence on the metric row rather than beside the heading, and the heading opens the measurement history filtered to that metric.
+- The danger zone and the daily-briefing card match the rest of the surface; the feedback tool is marked deprecated; the preventive-care and labs settings follow the standard section layout.
+- Lab values: delete moved to the value's detail view, the overview tags are quieter, and the reference-range confidence is surfaced when a new biomarker is created.
+- Location lookups default to the online resolver; the offline database is optional.
+- Mobile: the full-page Coach composer stays clear of the bottom navigation, long navigation labels stay readable, and touch targets meet 44 px.
+
+### Performance and integrity
+
+- Blood-pressure in-target and grade read identically whether served warm or cold (one canonical source per day).
+- A per-session statement timeout stops a single stuck query from holding a connection; a soft-delete-aware measurements index; the all-time fallback scan is bounded.
+
+### Security
+
+- Daily-briefing numbers are checked against the underlying signals; the Coach reply is screened for fabricated values or dose instructions; the period narrative is grounded before it is stored; the metric status cards state precomputed trends rather than re-deriving them.
+
+### API
+
+- The labs OCR capability response carries a `mode` (vision or text) and a new local-OCR preference; additional measurement types are recorded server-side. All additive and back-compatible.
+
## [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).
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 1a90c78ad..3b9bacaeb 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.9
+ version: 1.18.10
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -3606,6 +3606,22 @@ paths:
- NIGHTSCOUT
- POLAR
- OURA
+ stress:
+ maxItems: 8
+ type: array
+ items:
+ type: string
+ enum:
+ - MANUAL
+ - WITHINGS
+ - IMPORT
+ - APPLE_HEALTH
+ - COMPUTED
+ - WHOOP
+ - FITBIT
+ - NIGHTSCOUT
+ - POLAR
+ - OURA
metricPriority:
type: object
properties:
@@ -3881,6 +3897,22 @@ paths:
- NIGHTSCOUT
- POLAR
- OURA
+ stress:
+ maxItems: 8
+ type: array
+ items:
+ type: string
+ enum:
+ - MANUAL
+ - WITHINGS
+ - IMPORT
+ - APPLE_HEALTH
+ - COMPUTED
+ - WHOOP
+ - FITBIT
+ - NIGHTSCOUT
+ - POLAR
+ - OURA
deviceTypePriority:
type: object
properties:
@@ -6285,12 +6317,15 @@ paths:
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.
+ summary: Extract lab readings from a photo, PDF, or OCR'd text
+ description: "Read-only (NOT idempotent) extraction. Two modes by content-type. VISION (`multipart/form-data`): a `file`
+ (JPEG/PNG/WebP, or PDF on an Anthropic vision provider; ≤ 12 MiB) is run through the user's vision-capable
+ provider; the upload lives in memory only and is never persisted or logged. TEXT (`application/json`, opt-in
+ local OCR): the browser OCR's the image (tesseract.js) and POSTs `{ mode: \"text\", text }` — only the extracted
+ text reaches the server, so a text-only provider (e.g. ChatGPT-OAuth) reaches the same review/commit flow. Both
+ modes are gated by AI consent, a 6/hour rate bucket, and the per-day token budget, and return proposed rows for
+ the mandatory human review screen — nothing is written. Extracted content is treated as untrusted
+ (prompt-injection); the review step is the safety boundary."
requestBody:
required: true
content:
@@ -6304,6 +6339,9 @@ paths:
description: The lab-report image or PDF. Validated by magic-byte MIME sniff, not the wire Content-Type.
required:
- file
+ application/json:
+ schema:
+ $ref: "#/components/schemas/OcrTextExtractRequest"
responses:
"200":
description: Proposed rows for review.
@@ -8429,6 +8467,18 @@ components:
type: string
required:
- ids
+ OcrTextExtractRequest:
+ type: object
+ properties:
+ mode:
+ type: string
+ const: text
+ text:
+ type: string
+ description: The in-browser-OCR'd lab-report text. The raw image never reaches the server in this mode.
+ required:
+ - mode
+ - text
DisclaimerAckRequest:
type: object
properties:
@@ -14406,6 +14456,22 @@ components:
- NIGHTSCOUT
- POLAR
- OURA
+ stress:
+ maxItems: 8
+ type: array
+ items:
+ type: string
+ enum:
+ - MANUAL
+ - WITHINGS
+ - IMPORT
+ - APPLE_HEALTH
+ - COMPUTED
+ - WHOOP
+ - FITBIT
+ - NIGHTSCOUT
+ - POLAR
+ - OURA
metricPriority:
type: object
properties:
@@ -14681,6 +14747,22 @@ components:
- NIGHTSCOUT
- POLAR
- OURA
+ stress:
+ maxItems: 8
+ type: array
+ items:
+ type: string
+ enum:
+ - MANUAL
+ - WITHINGS
+ - IMPORT
+ - APPLE_HEALTH
+ - COMPUTED
+ - WHOOP
+ - FITBIT
+ - NIGHTSCOUT
+ - POLAR
+ - OURA
additionalProperties: false
deviceTypePriority:
type: object
@@ -15002,6 +15084,22 @@ components:
- NIGHTSCOUT
- POLAR
- OURA
+ stress:
+ maxItems: 8
+ type: array
+ items:
+ type: string
+ enum:
+ - MANUAL
+ - WITHINGS
+ - IMPORT
+ - APPLE_HEALTH
+ - COMPUTED
+ - WHOOP
+ - FITBIT
+ - NIGHTSCOUT
+ - POLAR
+ - OURA
metricPriority:
type: object
properties:
@@ -15277,6 +15375,22 @@ components:
- NIGHTSCOUT
- POLAR
- OURA
+ stress:
+ maxItems: 8
+ type: array
+ items:
+ type: string
+ enum:
+ - MANUAL
+ - WITHINGS
+ - IMPORT
+ - APPLE_HEALTH
+ - COMPUTED
+ - WHOOP
+ - FITBIT
+ - NIGHTSCOUT
+ - POLAR
+ - OURA
additionalProperties: false
deviceTypePriority:
type: object
@@ -19234,21 +19348,32 @@ components:
properties:
available:
type: boolean
+ mode:
+ anyOf:
+ - type: string
+ enum:
+ - vision
+ - text
+ - type: "null"
reason:
anyOf:
- type: string
enum:
- no-provider
- text-only-model
+ - enable-local-ocr
- type: "null"
pdfSupported:
type: boolean
required:
- available
+ - mode
- reason
- pdfSupported
additionalProperties: false
- description: Whether the caller's configured AI provider can read images (drives the UI's scan affordance). `reason`
+ description: Whether the caller's configured AI provider can ingest a lab report (drives the UI's scan affordance).
+ `mode` is `vision` when the provider reads the image directly, `text` when the image is OCR'd in-browser and
+ only the extracted text is sent (opt-in local OCR for text-only providers), or null when unavailable. `reason`
explains an unavailable state; `pdfSupported` is true only for an Anthropic vision provider (native PDF). No
provider call is made.
OcrExtractEnvelope:
diff --git a/eslint-plugins/healthlog/api-fetch-required.js b/eslint-plugins/healthlog/api-fetch-required.js
index 028bb33ce..67da72ca5 100644
--- a/eslint-plugins/healthlog/api-fetch-required.js
+++ b/eslint-plugins/healthlog/api-fetch-required.js
@@ -43,8 +43,16 @@ const EXEMPT_FILES = ["src/lib/api/api-fetch.ts"];
// server-side lib code never self-calls `/api/` routes, and the
// safe-fetch rule already covers its outbound calls. `src/hooks/` is
// client-only by construction (v1.16.4) — its `/api/` reads route
-// through the wrapper like every component.
-const ENFORCED_ROOTS = ["src/components/", "src/app/", "src/hooks/"];
+// through the wrapper like every component. `src/lib/queries/` is the
+// one `src/lib/` exception: it holds client-only TanStack-Query hooks
+// (every file is `"use client"`), so its `/api/` reads route through the
+// wrapper too.
+const ENFORCED_ROOTS = [
+ "src/components/",
+ "src/app/",
+ "src/hooks/",
+ "src/lib/queries/",
+];
function toPosix(filename) {
return filename.replace(/\\/g, "/");
diff --git a/messages/de.json b/messages/de.json
index 40d7ddbd8..53988cf76 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -2268,6 +2268,8 @@
"sourcesMemberCount": "{count} Werte",
"dictate": "Diktieren",
"dictateStop": "Diktat beenden",
+ "voiceUnsupported": "Spracheingabe wird in diesem Browser nicht unterstützt",
+ "dictateError": "Spracheingabe konnte nicht gestartet werden. Prüfe die Mikrofonberechtigung und versuche es erneut.",
"showConversations": "Unterhaltungen anzeigen",
"hideConversations": "Unterhaltungen ausblenden",
"heroGreeting": "Wie kann ich dir helfen?",
@@ -3870,7 +3872,8 @@
"vo2Max": "VO2 max",
"skinTemperature": "Hauttemperatur",
"respiratoryRate": "Atemfrequenz",
- "recovery": "Erholung"
+ "recovery": "Erholung",
+ "stress": "Stress"
},
"sourceLabels": {
"WITHINGS": "Withings",
@@ -4931,6 +4934,8 @@
"bugReportGithubDescription": "Hinterlege GitHub-Issue-Token und Repository für eingehende Bugreports.",
"bugReportEnabledLabel": "Bug-Reports aktiviert",
"bugReportEnabledDescription": "Wenn deaktiviert, ist der Report-Button im Frontend nicht mehr sichtbar — Token und Repository bleiben gespeichert.",
+ "bugReportDeprecatedBadge": "Veraltet",
+ "bugReportDeprecatedNote": "Diese Funktion ist veraltet und wird in einer künftigen Version entfernt.",
"assistant": {
"title": "Assistenz-Oberflächen",
"description": "Jeder Toggle versteckt eine modellgestützte Oberfläche app-weit, ohne die Provider-Konfiguration zu entfernen. Der Hauptschalter erzwingt jede Unter-Option aus.",
@@ -6470,6 +6475,7 @@
"viewDescription": "Lege fest, ob die Einträge als Karten oder als kompakte Liste angezeigt werden.",
"reorder": {
"heading": "Reihenfolge",
+ "description": "Ziehe die Einträge, um ihre Reihenfolge festzulegen.",
"empty": "Noch keine Einträge zum Sortieren."
}
},
@@ -6489,6 +6495,10 @@
"deleteError": "Wert konnte nicht gelöscht werden",
"loadError": "Laborwerte konnten nicht geladen werden",
"customize": "Laborwerte anpassen",
+ "localOcr": {
+ "heading": "Scans auf diesem Gerät lesen",
+ "description": "Ermöglicht das Scannen eines Laborbefunds, auch wenn dein KI-Anbieter keine Bilder lesen kann: Das Foto wird auf deinem Gerät gelesen und nur der erkannte Text an deinen Anbieter gesendet. Weniger genau – prüfe jeden Wert vor dem Speichern."
+ },
"sort": {
"heading": "Sortierung",
"label": "Reihenfolge der Werte",
@@ -6640,10 +6650,16 @@
"showingLatestOf": "Die neuesten {shown} von {total} Messwerten werden angezeigt",
"ocr": {
"scanButton": "Befund scannen",
+ "addScan": "Dokument scannen",
+ "addManual": "Wert manuell hinzufügen",
"uploadHint": "Foto oder PDF eines Laborbefunds hochladen",
"acceptWithPdf": "JPEG, PNG, WebP oder PDF",
"acceptImageOnly": "JPEG, PNG oder WebP",
"extracting": "Befund wird gelesen…",
+ "readingOnDevice": "Wird auf deinem Gerät gelesen…",
+ "localModeHint": "Wird auf deinem Gerät gelesen und als Text gesendet. Weniger genau – prüfe jeden Wert.",
+ "rangeFromScan": "Referenzbereich aus dem Scan übernommen",
+ "rangeVerify": "Referenzbereich prüfen",
"reviewTitle": "Vor dem Speichern prüfen",
"reviewHint": "Prüfe jeden Wert und speichere die gewünschten Einträge.",
"foundCount": "{count} Werte gefunden",
diff --git a/messages/en.json b/messages/en.json
index 54241ad76..7e15661ef 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -2268,6 +2268,8 @@
"sourcesMemberCount": "{count} metrics",
"dictate": "Dictate",
"dictateStop": "Stop dictation",
+ "voiceUnsupported": "Voice input is not supported in this browser",
+ "dictateError": "Could not start voice input. Check microphone permission and try again.",
"showConversations": "Show conversations",
"hideConversations": "Hide conversations",
"heroGreeting": "How can I help you?",
@@ -3870,7 +3872,8 @@
"vo2Max": "VO2 max",
"skinTemperature": "Skin temperature",
"respiratoryRate": "Respiratory rate",
- "recovery": "Recovery"
+ "recovery": "Recovery",
+ "stress": "Stress"
},
"sourceLabels": {
"WITHINGS": "Withings",
@@ -4931,6 +4934,8 @@
"bugReportGithubDescription": "Configure GitHub issue token and repository for incoming bug reports.",
"bugReportEnabledLabel": "Bug reports enabled",
"bugReportEnabledDescription": "When off, the in-app report button hides — token and repository stay configured.",
+ "bugReportDeprecatedBadge": "Deprecated",
+ "bugReportDeprecatedNote": "This feature is deprecated and will be removed in a future version.",
"assistant": {
"title": "Assistant Surfaces",
"description": "Each toggle hides a model-driven surface app-wide without removing the provider configuration. The master kills every sub-flag at once.",
@@ -6470,6 +6475,7 @@
"viewDescription": "Choose whether entries show as cards or as a compact list.",
"reorder": {
"heading": "Order",
+ "description": "Drag the entries to set the order they appear in.",
"empty": "No entries to reorder yet."
}
},
@@ -6489,6 +6495,10 @@
"deleteError": "Could not delete the result",
"loadError": "Could not load lab results",
"customize": "Customize labs",
+ "localOcr": {
+ "heading": "Read scans on this device",
+ "description": "Lets you scan a lab report even when your AI provider can't read images: the photo is read on your device and only the extracted text is sent to your provider. Less accurate — review every value before saving."
+ },
"sort": {
"heading": "Sort order",
"label": "Order of values",
@@ -6640,10 +6650,16 @@
"showingLatestOf": "Showing the latest {shown} of {total} readings",
"ocr": {
"scanButton": "Scan a report",
+ "addScan": "Scan a document",
+ "addManual": "Add a value manually",
"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…",
+ "readingOnDevice": "Reading on your device…",
+ "localModeHint": "Read on your device, then sent as text. Less accurate — review every value.",
+ "rangeFromScan": "Reference range taken from the scan",
+ "rangeVerify": "Verify the reference range",
"reviewTitle": "Review before saving",
"reviewHint": "Check each reading, then save the ones you want to keep.",
"foundCount": "{count} readings found",
diff --git a/messages/es.json b/messages/es.json
index 4b8561ed3..f283e8fd7 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -2268,6 +2268,8 @@
"sourcesMemberCount": "{count} métricas",
"dictate": "Dictar",
"dictateStop": "Detener el dictado",
+ "voiceUnsupported": "La entrada de voz no es compatible con este navegador",
+ "dictateError": "No se pudo iniciar la entrada de voz. Comprueba el permiso del micrófono e inténtalo de nuevo.",
"showConversations": "Mostrar conversaciones",
"hideConversations": "Ocultar conversaciones",
"heroGreeting": "¿En qué puedo ayudarte?",
@@ -3870,7 +3872,8 @@
"vo2Max": "VO2 máx.",
"skinTemperature": "Temperatura de la piel",
"respiratoryRate": "Frecuencia respiratoria",
- "recovery": "Recuperación"
+ "recovery": "Recuperación",
+ "stress": "Estrés"
},
"sourceLabels": {
"WITHINGS": "Withings",
@@ -4931,6 +4934,8 @@
"bugReportGithubDescription": "Configura el token de issues de GitHub y el repositorio para los informes de errores entrantes.",
"bugReportEnabledLabel": "Informes de errores activados",
"bugReportEnabledDescription": "Cuando está desactivado, el botón de informe en la app se oculta; el token y el repositorio permanecen configurados.",
+ "bugReportDeprecatedBadge": "Obsoleto",
+ "bugReportDeprecatedNote": "Esta función está obsoleta y se eliminará en una versión futura.",
"assistant": {
"title": "Superficies del asistente",
"description": "Cada conmutador oculta una superficie impulsada por el modelo en toda la aplicación sin retirar la configuración del proveedor. El interruptor maestro fuerza cada opción secundaria a off.",
@@ -6470,6 +6475,7 @@
"viewDescription": "Elige si las entradas se muestran como tarjetas o como lista compacta.",
"reorder": {
"heading": "Orden",
+ "description": "Arrastra las entradas para definir el orden en que aparecen.",
"empty": "Aún no hay entradas para ordenar."
}
},
@@ -6489,6 +6495,10 @@
"deleteError": "No se pudo eliminar el resultado",
"loadError": "No se pudieron cargar los resultados",
"customize": "Personalizar laboratorio",
+ "localOcr": {
+ "heading": "Leer escaneos en este dispositivo",
+ "description": "Permite escanear un informe de laboratorio aunque tu proveedor de IA no pueda leer imágenes: la foto se lee en tu dispositivo y solo se envía el texto extraído a tu proveedor. Menos preciso: revisa cada valor antes de guardar."
+ },
"sort": {
"heading": "Orden",
"label": "Orden de los valores",
@@ -6640,10 +6650,16 @@
"showingLatestOf": "Mostrando los {shown} resultados más recientes de {total}",
"ocr": {
"scanButton": "Escanear un informe",
+ "addScan": "Escanear un documento",
+ "addManual": "Añadir un valor manualmente",
"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…",
+ "readingOnDevice": "Leyendo en tu dispositivo…",
+ "localModeHint": "Se lee en tu dispositivo y se envía como texto. Menos preciso: revisa cada valor.",
+ "rangeFromScan": "Intervalo de referencia tomado del escaneo",
+ "rangeVerify": "Verifica el intervalo de referencia",
"reviewTitle": "Revisar antes de guardar",
"reviewHint": "Revisa cada valor y guarda los que quieras conservar.",
"foundCount": "{count} valores encontrados",
diff --git a/messages/fr.json b/messages/fr.json
index 71ec8f395..b58eff6cb 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -2268,6 +2268,8 @@
"sourcesMemberCount": "{count} mesures",
"dictate": "Dicter",
"dictateStop": "Arrêter la dictée",
+ "voiceUnsupported": "La saisie vocale n'est pas prise en charge par ce navigateur",
+ "dictateError": "Impossible de démarrer la saisie vocale. Vérifiez l'autorisation du microphone et réessayez.",
"showConversations": "Afficher les conversations",
"hideConversations": "Masquer les conversations",
"heroGreeting": "Comment puis-je t'aider ?",
@@ -3870,7 +3872,8 @@
"vo2Max": "VO2 max",
"skinTemperature": "Température cutanée",
"respiratoryRate": "Fréquence respiratoire",
- "recovery": "Récupération"
+ "recovery": "Récupération",
+ "stress": "Stress"
},
"sourceLabels": {
"WITHINGS": "Withings",
@@ -4931,6 +4934,8 @@
"bugReportGithubDescription": "Configurez le jeton d'issues GitHub et le dépôt pour les rapports de bug entrants.",
"bugReportEnabledLabel": "Rapports de bug activés",
"bugReportEnabledDescription": "Lorsque cette option est désactivée, le bouton de signalement dans l'app est masqué — le jeton et le dépôt restent configurés.",
+ "bugReportDeprecatedBadge": "Obsolète",
+ "bugReportDeprecatedNote": "Cette fonctionnalité est obsolète et sera supprimée dans une version future.",
"assistant": {
"title": "Surfaces d'assistance",
"description": "Chaque bascule masque une surface pilotée par le modèle dans toute l'application sans retirer la configuration du fournisseur. Le commutateur principal force chaque option secondaire à off.",
@@ -6470,6 +6475,7 @@
"viewDescription": "Choisissez si les entrées s'affichent en cartes ou en liste compacte.",
"reorder": {
"heading": "Ordre",
+ "description": "Faites glisser les entrées pour définir leur ordre d'affichage.",
"empty": "Aucune entrée à réordonner pour l'instant."
}
},
@@ -6489,6 +6495,10 @@
"deleteError": "Impossible de supprimer le résultat",
"loadError": "Impossible de charger les résultats",
"customize": "Personnaliser les analyses",
+ "localOcr": {
+ "heading": "Lire les scans sur cet appareil",
+ "description": "Permet de scanner un compte rendu de laboratoire même si votre fournisseur d'IA ne peut pas lire les images : la photo est lue sur votre appareil et seul le texte extrait est envoyé à votre fournisseur. Moins précis — vérifiez chaque valeur avant d'enregistrer."
+ },
"sort": {
"heading": "Tri",
"label": "Ordre des valeurs",
@@ -6640,10 +6650,16 @@
"showingLatestOf": "Affichage des {shown} derniers résultats sur {total}",
"ocr": {
"scanButton": "Scanner un compte rendu",
+ "addScan": "Scanner un document",
+ "addManual": "Ajouter une valeur manuellement",
"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…",
+ "readingOnDevice": "Lecture sur votre appareil…",
+ "localModeHint": "Lu sur votre appareil, puis envoyé sous forme de texte. Moins précis — vérifiez chaque valeur.",
+ "rangeFromScan": "Valeurs de référence issues du scan",
+ "rangeVerify": "Vérifiez les valeurs de référence",
"reviewTitle": "Vérifier avant d'enregistrer",
"reviewHint": "Vérifiez chaque valeur, puis enregistrez celles à conserver.",
"foundCount": "{count} valeurs trouvées",
diff --git a/messages/it.json b/messages/it.json
index 712b5c259..cc2c72640 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -2268,6 +2268,8 @@
"sourcesMemberCount": "{count} metriche",
"dictate": "Detta",
"dictateStop": "Interrompi dettatura",
+ "voiceUnsupported": "L'inserimento vocale non è supportato in questo browser",
+ "dictateError": "Impossibile avviare l'inserimento vocale. Controlla l'autorizzazione del microfono e riprova.",
"showConversations": "Mostra conversazioni",
"hideConversations": "Nascondi conversazioni",
"heroGreeting": "Come posso aiutarti?",
@@ -3870,7 +3872,8 @@
"vo2Max": "VO2 max",
"skinTemperature": "Temperatura cutanea",
"respiratoryRate": "Frequenza respiratoria",
- "recovery": "Recupero"
+ "recovery": "Recupero",
+ "stress": "Stress"
},
"sourceLabels": {
"WITHINGS": "Withings",
@@ -4931,6 +4934,8 @@
"bugReportGithubDescription": "Configura il token delle issue di GitHub e il repository per le segnalazioni di bug in arrivo.",
"bugReportEnabledLabel": "Segnalazioni di bug attive",
"bugReportEnabledDescription": "Se disattivato, il pulsante di segnalazione nell'app viene nascosto: token e repository restano configurati.",
+ "bugReportDeprecatedBadge": "Obsoleto",
+ "bugReportDeprecatedNote": "Questa funzione è obsoleta e verrà rimossa in una versione futura.",
"assistant": {
"title": "Superfici dell'assistente",
"description": "Ogni interruttore nasconde una superficie guidata dal modello in tutta l'app senza rimuovere la configurazione del provider. Il master forza ogni opzione secondaria su off.",
@@ -6470,6 +6475,7 @@
"viewDescription": "Scegli se le voci appaiono come schede o come elenco compatto.",
"reorder": {
"heading": "Ordine",
+ "description": "Trascina le voci per definire l'ordine in cui appaiono.",
"empty": "Nessuna voce da riordinare."
}
},
@@ -6489,6 +6495,10 @@
"deleteError": "Impossibile eliminare il risultato",
"loadError": "Impossibile caricare i risultati",
"customize": "Personalizza esami",
+ "localOcr": {
+ "heading": "Leggi le scansioni su questo dispositivo",
+ "description": "Consente di scansionare un referto di laboratorio anche se il tuo provider di IA non può leggere le immagini: la foto viene letta sul tuo dispositivo e al provider viene inviato solo il testo estratto. Meno preciso: controlla ogni valore prima di salvare."
+ },
"sort": {
"heading": "Ordinamento",
"label": "Ordine dei valori",
@@ -6640,10 +6650,16 @@
"showingLatestOf": "Visualizzazione degli ultimi {shown} risultati su {total}",
"ocr": {
"scanButton": "Scansiona un referto",
+ "addScan": "Scansiona un documento",
+ "addManual": "Aggiungi un valore manualmente",
"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…",
+ "readingOnDevice": "Lettura sul tuo dispositivo…",
+ "localModeHint": "Letto sul tuo dispositivo e inviato come testo. Meno preciso: controlla ogni valore.",
+ "rangeFromScan": "Intervallo di riferimento ricavato dalla scansione",
+ "rangeVerify": "Verifica l'intervallo di riferimento",
"reviewTitle": "Controlla prima di salvare",
"reviewHint": "Controlla ogni valore, poi salva quelli da conservare.",
"foundCount": "{count} valori trovati",
diff --git a/messages/pl.json b/messages/pl.json
index 832a4d5d2..f5ea68ef1 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -2268,6 +2268,8 @@
"sourcesMemberCount": "{count} wskaźników",
"dictate": "Dyktuj",
"dictateStop": "Zatrzymaj dyktowanie",
+ "voiceUnsupported": "Wprowadzanie głosowe nie jest obsługiwane w tej przeglądarce",
+ "dictateError": "Nie udało się uruchomić wprowadzania głosowego. Sprawdź uprawnienia mikrofonu i spróbuj ponownie.",
"showConversations": "Pokaż rozmowy",
"hideConversations": "Ukryj rozmowy",
"heroGreeting": "W czym mogę pomóc?",
@@ -3870,7 +3872,8 @@
"vo2Max": "VO2 max",
"skinTemperature": "Temperatura skóry",
"respiratoryRate": "Częstość oddechów",
- "recovery": "Regeneracja"
+ "recovery": "Regeneracja",
+ "stress": "Stres"
},
"sourceLabels": {
"WITHINGS": "Withings",
@@ -4931,6 +4934,8 @@
"bugReportGithubDescription": "Skonfiguruj token zgłoszeń GitHub i repozytorium dla przychodzących zgłoszeń błędów.",
"bugReportEnabledLabel": "Zgłaszanie błędów włączone",
"bugReportEnabledDescription": "Gdy wyłączone, przycisk zgłaszania w aplikacji jest ukryty — token i repozytorium pozostają skonfigurowane.",
+ "bugReportDeprecatedBadge": "Wycofane",
+ "bugReportDeprecatedNote": "Ta funkcja jest wycofywana i zostanie usunięta w przyszłej wersji.",
"assistant": {
"title": "Powierzchnie asystenta",
"description": "Każdy przełącznik ukrywa powierzchnię opartą na modelu w całej aplikacji bez usuwania konfiguracji dostawcy. Główny wyłącznik wymusza wyłączenie każdej opcji podrzędnej.",
@@ -6470,6 +6475,7 @@
"viewDescription": "Wybierz, czy pozycje mają być pokazywane jako karty czy jako zwarta lista.",
"reorder": {
"heading": "Kolejność",
+ "description": "Przeciągnij pozycje, aby ustawić kolejność ich wyświetlania.",
"empty": "Brak pozycji do uporządkowania."
}
},
@@ -6489,6 +6495,10 @@
"deleteError": "Nie udało się usunąć wyniku",
"loadError": "Nie udało się wczytać wyników",
"customize": "Dostosuj wyniki badań",
+ "localOcr": {
+ "heading": "Odczytuj skany na tym urządzeniu",
+ "description": "Pozwala zeskanować wynik laboratoryjny nawet wtedy, gdy Twój dostawca AI nie potrafi odczytywać obrazów: zdjęcie jest odczytywane na Twoim urządzeniu, a do dostawcy wysyłany jest tylko wyodrębniony tekst. Mniej dokładne — sprawdź każdą wartość przed zapisaniem."
+ },
"sort": {
"heading": "Sortowanie",
"label": "Kolejność wartości",
@@ -6640,10 +6650,16 @@
"showingLatestOf": "Wyświetlanie najnowszych {shown} z {total} wyników",
"ocr": {
"scanButton": "Zeskanuj wynik",
+ "addScan": "Zeskanuj dokument",
+ "addManual": "Dodaj wartość ręcznie",
"uploadHint": "Prześlij zdjęcie lub PDF wyniku laboratoryjnego",
"acceptWithPdf": "JPEG, PNG, WebP lub PDF",
"acceptImageOnly": "JPEG, PNG lub WebP",
"extracting": "Odczytywanie wyniku…",
+ "readingOnDevice": "Odczytywanie na Twoim urządzeniu…",
+ "localModeHint": "Odczytywane na Twoim urządzeniu i wysyłane jako tekst. Mniej dokładne — sprawdź każdą wartość.",
+ "rangeFromScan": "Zakres referencyjny pobrany ze skanu",
+ "rangeVerify": "Zweryfikuj zakres referencyjny",
"reviewTitle": "Sprawdź przed zapisaniem",
"reviewHint": "Sprawdź każdy wynik, a następnie zapisz te, które chcesz zachować.",
"foundCount": "Znaleziono {count} wyników",
diff --git a/package.json b/package.json
index 3108f4568..de78ab5e0 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "healthlog",
- "version": "1.18.9",
+ "version": "1.18.10",
"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",
@@ -87,6 +87,7 @@
"sax": "^1.6.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
+ "tesseract.js": "^7.0.0",
"undici": "^8.5.0",
"web-push": "^3.6.7",
"yaml": "^2.8.4",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a1db36f88..e75635217 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -123,6 +123,9 @@ importers:
tailwind-merge:
specifier: ^3.6.0
version: 3.6.0
+ tesseract.js:
+ specifier: ^7.0.0
+ version: 7.0.0
undici:
specifier: ^8.5.0
version: 8.5.0
@@ -3373,6 +3376,9 @@ packages:
bl@4.1.0:
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
+ bmp-js@0.1.0:
+ resolution: {integrity: sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==}
+
bn.js@4.12.3:
resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==}
@@ -4454,6 +4460,9 @@ packages:
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
engines: {node: '>=0.10.0'}
+ idb-keyval@6.2.5:
+ resolution: {integrity: sha512-eKQkTnS0relYsSOYomx8ozIbmdsQCKUdhyuIaQ2DZgKuaxtyQQMkyD/wlnQN32pO3yutN1b1L8uqwcDKaJd7/Q==}
+
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
@@ -4658,6 +4667,9 @@ packages:
resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==}
engines: {node: '>=18'}
+ is-url@1.2.4:
+ resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==}
+
is-weakmap@2.0.2:
resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==}
engines: {node: '>= 0.4'}
@@ -5184,6 +5196,15 @@ packages:
resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==}
engines: {node: '>= 0.4'}
+ node-fetch@2.7.0:
+ resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
+ engines: {node: 4.x || >=6.0.0}
+ peerDependencies:
+ encoding: ^0.1.0
+ peerDependenciesMeta:
+ encoding:
+ optional: true
+
node-fetch@3.3.2:
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@@ -5277,6 +5298,10 @@ packages:
resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==}
engines: {node: '>=20'}
+ opencollective-postinstall@2.0.3:
+ resolution: {integrity: sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==}
+ hasBin: true
+
opener@1.5.2:
resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==}
hasBin: true
@@ -6197,6 +6222,12 @@ packages:
teex@1.0.1:
resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==}
+ tesseract.js-core@7.0.0:
+ resolution: {integrity: sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw==}
+
+ tesseract.js@7.0.0:
+ resolution: {integrity: sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==}
+
testcontainers@12.0.1:
resolution: {integrity: sha512-EMjjfMNJf3HlL7V3elkxqKUO1r3CtqNBTdmKGwwma/lOtUGfoWvFJ0WQ/KQf1DHEMnRjLWzW4cXbv/Tndsbcbw==}
@@ -6255,6 +6286,9 @@ packages:
resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==}
engines: {node: '>=16'}
+ tr46@0.0.3:
+ resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
+
ts-api-utils@2.5.0:
resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
engines: {node: '>=18.12'}
@@ -6515,6 +6549,9 @@ packages:
resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==}
engines: {node: 20 || >=22}
+ wasm-feature-detect@1.8.0:
+ resolution: {integrity: sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==}
+
web-push@3.6.7:
resolution: {integrity: sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==}
engines: {node: '>= 16'}
@@ -6524,11 +6561,17 @@ packages:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
engines: {node: '>= 8'}
+ webidl-conversions@3.0.1:
+ resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
+
webpack-bundle-analyzer@4.10.1:
resolution: {integrity: sha512-s3P7pgexgT/HTUSYgxJyn28A+99mmLq4HsJepMPzu0R8ImJc52QNqaFYW1Z2z2uIb1/J3eYgaAWVpaC+v/1aAQ==}
engines: {node: '>= 10.13.0'}
hasBin: true
+ whatwg-url@5.0.0:
+ resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
+
which-boxed-primitive@1.1.1:
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
engines: {node: '>= 0.4'}
@@ -6665,6 +6708,9 @@ packages:
resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==}
engines: {node: '>= 14'}
+ zlibjs@0.3.1:
+ resolution: {integrity: sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==}
+
zod-openapi@6.0.0:
resolution: {integrity: sha512-mS4eRJ4DGCPrg6elRbJqc/3nLe4EPVi8KiHRKZ7dcTR5m5orPy8EfoWmceAyGZAq71MAWuyrTTOag7W5N61ZPQ==}
engines: {node: '>=22.14.0'}
@@ -9906,6 +9952,8 @@ snapshots:
inherits: 2.0.4
readable-stream: 3.6.2
+ bmp-js@0.1.0: {}
+
bn.js@4.12.3: {}
body-parser@2.2.2:
@@ -11165,6 +11213,8 @@ snapshots:
dependencies:
safer-buffer: 2.1.2
+ idb-keyval@6.2.5: {}
+
ieee754@1.2.1: {}
ignore@5.3.2: {}
@@ -11335,6 +11385,8 @@ snapshots:
is-unicode-supported@2.1.0: {}
+ is-url@1.2.4: {}
+
is-weakmap@2.0.2: {}
is-weakref@1.1.1:
@@ -11824,6 +11876,10 @@ snapshots:
object.entries: 1.1.9
semver: 6.3.1
+ node-fetch@2.7.0:
+ dependencies:
+ whatwg-url: 5.0.0
+
node-fetch@3.3.2:
dependencies:
data-uri-to-buffer: 4.0.1
@@ -11922,6 +11978,8 @@ snapshots:
powershell-utils: 0.1.0
wsl-utils: 0.3.1
+ opencollective-postinstall@2.0.3: {}
+
opener@1.5.2: {}
optionator@0.9.4:
@@ -12500,8 +12558,7 @@ snapshots:
get-proto: 1.0.1
which-builtin-type: 1.2.1
- regenerator-runtime@0.13.11:
- optional: true
+ regenerator-runtime@0.13.11: {}
regexp.prototype.flags@1.5.4:
dependencies:
@@ -13052,6 +13109,22 @@ snapshots:
- bare-abort-controller
- react-native-b4a
+ tesseract.js-core@7.0.0: {}
+
+ tesseract.js@7.0.0:
+ dependencies:
+ bmp-js: 0.1.0
+ idb-keyval: 6.2.5
+ is-url: 1.2.4
+ node-fetch: 2.7.0
+ opencollective-postinstall: 2.0.3
+ regenerator-runtime: 0.13.11
+ tesseract.js-core: 7.0.0
+ wasm-feature-detect: 1.8.0
+ zlibjs: 0.3.1
+ transitivePeerDependencies:
+ - encoding
+
testcontainers@12.0.1:
dependencies:
'@balena/dockerignore': 1.0.2
@@ -13124,6 +13197,8 @@ snapshots:
dependencies:
tldts: 7.4.2
+ tr46@0.0.3: {}
+
ts-api-utils@2.5.0(typescript@6.0.3):
dependencies:
typescript: 6.0.3
@@ -13379,6 +13454,8 @@ snapshots:
walk-up-path@4.0.0: {}
+ wasm-feature-detect@1.8.0: {}
+
web-push@3.6.7:
dependencies:
asn1.js: 5.4.1
@@ -13391,6 +13468,8 @@ snapshots:
web-streams-polyfill@3.3.3: {}
+ webidl-conversions@3.0.1: {}
+
webpack-bundle-analyzer@4.10.1:
dependencies:
'@discoveryjs/json-ext': 0.5.7
@@ -13410,6 +13489,11 @@ snapshots:
- bufferutil
- utf-8-validate
+ whatwg-url@5.0.0:
+ dependencies:
+ tr46: 0.0.3
+ webidl-conversions: 3.0.1
+
which-boxed-primitive@1.1.1:
dependencies:
is-bigint: 1.1.0
@@ -13561,6 +13645,8 @@ snapshots:
compress-commons: 6.0.2
readable-stream: 4.7.0
+ zlibjs@0.3.1: {}
+
zod-openapi@6.0.0(zod@4.4.3):
dependencies:
zod: 4.4.3
diff --git a/prisma/migrations/0183_v11810_measurements_live_covering_idx/migration.sql b/prisma/migrations/0183_v11810_measurements_live_covering_idx/migration.sql
new file mode 100644
index 000000000..201c3c841
--- /dev/null
+++ b/prisma/migrations/0183_v11810_measurements_live_covering_idx/migration.sql
@@ -0,0 +1,40 @@
+-- v1.18.10 P-5 — covering partial index for soft-delete-filtered reads.
+--
+-- The hot reads on `measurements` all filter `deleted_at IS NULL` and order
+-- `measured_at DESC` per type — the DISTINCT-ON-latest pass, the management
+-- list, the series reads, and the per-type count. The base
+-- `(user_id, type, measured_at)` index is ASC and does NOT carry
+-- `deleted_at`, so those reads pay a heap re-check of `deleted_at` over the
+-- type partition for every candidate row.
+--
+-- Two partial indexes already cover the highest-volume types
+-- (`measurements_dense_intraday_retention_idx` for HRV/PULSE, migration 0108;
+-- `measurements_legacy_step_consolidation_idx` for ACTIVITY_STEPS, migration
+-- 0087). This index closes the gap for every OTHER type: a covering partial
+-- index keyed `(user_id, type, measured_at DESC)` over live rows only serves
+-- the latest / list / series reads without the heap re-check, and — being
+-- partial on `deleted_at IS NULL` — it carries no entries for tombstoned rows.
+--
+-- Additive + order-safe: a pure `CREATE INDEX IF NOT EXISTS`, no column add,
+-- no backfill, no rewrite of `measurements`.
+--
+-- Prisma can't express a partial-predicate index, so it lives here in raw
+-- migration SQL and is intentionally omitted from `schema.prisma` (the same
+-- treatment migrations 0087 + 0108 use; a comment on the model documents it).
+--
+-- Idempotent guard (`IF NOT EXISTS`) makes reruns safe. Forward-only.
+--
+-- Lock note (conscious trade-off): a plain, non-`CONCURRENTLY` build, so it
+-- runs inside Prisma's migration transaction and holds an ACCESS EXCLUSIVE
+-- lock on `measurements` for the build. On the largest multi-year HealthKit
+-- tenants the live row set is large, so deploy at a low-traffic window. A
+-- `CONCURRENTLY` build cannot run inside the migration transaction and is not
+-- used here to keep the migration atomic with the rest of the release.
+--
+-- Reversibility:
+-- DROP INDEX IF EXISTS "measurements_live_covering_idx";
+-- A roll-back loses only the scan acceleration; no data is touched.
+
+CREATE INDEX IF NOT EXISTS "measurements_live_covering_idx"
+ ON "measurements" ("user_id", "type", "measured_at" DESC)
+ WHERE "deleted_at" IS NULL;
diff --git a/prisma/migrations/0184_v11810_labs_local_ocr/migration.sql b/prisma/migrations/0184_v11810_labs_local_ocr/migration.sql
new file mode 100644
index 000000000..0ebf91789
--- /dev/null
+++ b/prisma/migrations/0184_v11810_labs_local_ocr/migration.sql
@@ -0,0 +1,16 @@
+-- v1.18.10 — opt-in local (in-browser) OCR for lab-report scans.
+--
+-- One additive, defaulted column on `users`. When `true`, a user whose AI
+-- provider cannot read images (ChatGPT-OAuth/Codex, a text-only model) can
+-- still scan a paper report: the image is OCR'd in the browser via tesseract.js
+-- and only the extracted TEXT is forwarded to the text-only provider for
+-- structuring. The raw image never leaves the device in this mode.
+--
+-- Less accurate than a native vision model — the mandatory per-row review
+-- screen stays the safety backstop, and the in-app copy sets that expectation.
+-- The default `false` backfills every existing row onto the current behaviour
+-- (vision-only scan, or no scan affordance), so this is a no-op for anyone who
+-- never opts in.
+
+ALTER TABLE "users"
+ ADD COLUMN "labs_local_ocr_enabled" BOOLEAN NOT NULL DEFAULT false;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 480c5eaa2..b98b6f10d 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -207,6 +207,14 @@ model User {
// diagnosis — purely a user-declared preference that selects which target
// band the user is judged against. Default `false`.
hasDiabetes Boolean @default(false) @map("has_diabetes")
+ // v1.18.10 — opt-in local (in-browser) OCR for lab-report scans. When `true`,
+ // a user whose AI provider cannot read images (e.g. ChatGPT-OAuth/Codex, a
+ // text-only model) can still scan a report: the image is OCR'd in the browser
+ // via tesseract.js and only the extracted TEXT is sent to the text-only
+ // provider for structuring. Less accurate than native vision — the mandatory
+ // review screen remains the safety backstop. Default `false`; vision-capable
+ // users keep the higher-accuracy native path and never pay the WASM download.
+ labsLocalOcrEnabled Boolean @default(false) @map("labs_local_ocr_enabled")
// v1.7.0 unit pref — global metric/imperial display preference.
// Canonical storage stays SI; this only selects the display-time
// transform branch (km/h vs mph, km vs mi). Null = default "metric".
@@ -1113,6 +1121,12 @@ model Measurement {
// — see migration 0108. It accelerates the v1.10.0 dense intra-day
// retention drain's discovery + per-user scan. Same Prisma-can't-
// express-a-partial-predicate reason it is omitted here.
+ // `measurements_live_covering_idx` is a covering partial index over
+ // `(user_id, type, measured_at DESC) WHERE deleted_at IS NULL` — see
+ // migration 0183 (v1.18.10 P-5). It serves the DISTINCT-ON-latest, list,
+ // and series reads for every type NOT covered by the two partials above
+ // and removes the `deleted_at` heap re-check. Same Prisma-can't-express-a-
+ // partial-predicate reason it is omitted here.
@@map("measurements")
}
diff --git a/public/sw.js b/public/sw.js
index 2f60fa365..d66fd98bc 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.9";
+ /* @sw-version-fallback */ "v1.18.10";
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/api/auth/me/labs-local-ocr/route.ts b/src/app/api/auth/me/labs-local-ocr/route.ts
new file mode 100644
index 000000000..d015b773f
--- /dev/null
+++ b/src/app/api/auth/me/labs-local-ocr/route.ts
@@ -0,0 +1,118 @@
+/**
+ * v1.18.10 — per-user local-OCR opt-in endpoint.
+ *
+ * GET /api/auth/me/labs-local-ocr — current flag.
+ * PATCH /api/auth/me/labs-local-ocr — body `{ labsLocalOcrEnabled: boolean }`.
+ *
+ * When `true`, a user whose AI provider cannot read images (ChatGPT-OAuth/Codex,
+ * a text-only model) can still scan a paper lab report: the image is OCR'd in
+ * the browser via tesseract.js and only the extracted TEXT is forwarded to the
+ * text-only provider for structuring. The raw image never leaves the device in
+ * this mode. Less accurate than native vision — the mandatory review screen is
+ * the safety backstop.
+ *
+ * Mirrors the `diabetes` / `unit-preference` per-user-scalar pattern: 60/min
+ * rate limit, Zod safeParse → 422 via `returnAllZodIssues`, audit-log row,
+ * field-by-field write (no mass assignment). Idempotent — always returns the
+ * resolved next state so the client can hard-set the optimistic update.
+ */
+import { z } from "zod";
+
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import {
+ apiError,
+ apiSuccess,
+ getClientIp,
+ returnAllZodIssues,
+ safeJson,
+} from "@/lib/api-response";
+import { annotate } from "@/lib/logging/context";
+import { auditLog } from "@/lib/auth/audit";
+import { prisma } from "@/lib/db";
+import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
+
+const patchBodySchema = z.object({
+ labsLocalOcrEnabled: z.boolean(),
+});
+
+export const dynamic = "force-dynamic";
+
+const PATCH_RATE_LIMIT = 60;
+const PATCH_WINDOW_MS = 60_000;
+
+type LabsLocalOcrResponse = {
+ labsLocalOcrEnabled: boolean;
+};
+
+export const GET = apiHandler(async () => {
+ const { user } = await requireAuth();
+ annotate({ action: { name: "auth.me.labsLocalOcr.get" } });
+
+ const row = await prisma.user.findUnique({
+ where: { id: user.id },
+ select: { labsLocalOcrEnabled: true },
+ });
+ const payload: LabsLocalOcrResponse = {
+ labsLocalOcrEnabled: row?.labsLocalOcrEnabled ?? false,
+ };
+ return apiSuccess(payload);
+});
+
+export const PATCH = apiHandler(async (req: Request) => {
+ const { user } = await requireAuth();
+
+ const rl = await checkRateLimit(
+ `labs-local-ocr:patch:${user.id}`,
+ PATCH_RATE_LIMIT,
+ PATCH_WINDOW_MS,
+ );
+ if (!rl.allowed) {
+ const response = apiError("Too many requests", 429);
+ for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
+ response.headers.set(k, v);
+ }
+ return response;
+ }
+
+ // The body is a single boolean — bound the parse so a malformed or oversized
+ // payload is rejected before it is materialised.
+ const { data: body, error: jsonError } = await safeJson(req, {
+ maxBytes: 1024,
+ });
+ if (jsonError) return jsonError;
+
+ const parsed = patchBodySchema.safeParse(body);
+ if (!parsed.success) {
+ annotate({ action: { name: "auth.me.labsLocalOcr.patch.invalid_shape" } });
+ return returnAllZodIssues(parsed.error, 422);
+ }
+
+ const next = parsed.data.labsLocalOcrEnabled;
+
+ const previous = await prisma.user.findUnique({
+ where: { id: user.id },
+ select: { labsLocalOcrEnabled: true },
+ });
+
+ await prisma.user.update({
+ where: { id: user.id },
+ data: { labsLocalOcrEnabled: next },
+ });
+
+ await auditLog("user.labsLocalOcr.update", {
+ userId: user.id,
+ ipAddress: getClientIp(req),
+ details: {
+ previous: previous?.labsLocalOcrEnabled ?? false,
+ next,
+ },
+ });
+
+ annotate({
+ action: { name: "auth.me.labsLocalOcr.patch" },
+ meta: { labsLocalOcrEnabled: next },
+ });
+
+ const payload: LabsLocalOcrResponse = { labsLocalOcrEnabled: next };
+ return apiSuccess(payload);
+});
diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts
index ea8907ad7..f73812a66 100644
--- a/src/app/api/insights/chat/route.ts
+++ b/src/app/api/insights/chat/route.ts
@@ -66,6 +66,10 @@ import {
reconcileSpend,
} from "@/lib/ai/coach/budget";
import { detectRefusal } from "@/lib/ai/coach/refusal";
+import {
+ screenCoachReply,
+ coachOutboundFallback,
+} from "@/lib/ai/coach/outbound-guard";
import { getCoachSystemPrompt } from "@/lib/ai/coach/system-prompt";
import { getSelfContextTextForUser } from "@/lib/ai/coach/about-me";
import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot";
@@ -564,9 +568,33 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
// + dismissal memory + cooldown + dedup against a live COACH reminder).
// A suppressed proposal leaves the prose unchanged and emits no card.
const suggestParse = parseSuggestReminder(proseAfterStrip);
- const replyText = suggestParse.prose.trim() || proseAfterStrip;
+ let replyText = suggestParse.prose.trim() || proseAfterStrip;
+
+ // v1.18.10 (HIGH-2) — OUTBOUND safety screen on the assembled assistant
+ // reply, before persistence and streaming. The inbound `detectRefusal`
+ // guards the user's message; this guards the model's reply for a
+ // dose-prescription or a fabricated clinical risk score that slipped past
+ // the system-prompt GLP-1/grounding contracts. On a trip the turn is
+ // replaced with a calm, grounded fallback and any reminder suggestion /
+ // key-value provenance is dropped — the user never sees the unsafe text.
+ const outbound = screenCoachReply(replyText);
+ if (outbound.block && outbound.reason) {
+ replyText = coachOutboundFallback(outbound.reason, locale);
+ annotate({
+ action: { name: "insights.coach.outbound_blocked" },
+ meta: { reason: outbound.reason, promptVersion: PROMPT_VERSION },
+ });
+ await auditLog("insights.coach.outbound_blocked", {
+ userId,
+ details: {
+ conversationId: workingConversationId,
+ reason: outbound.reason,
+ },
+ });
+ }
+
let surfacedSuggestion: CoachSuggestion | null = null;
- if (suggestParse.cadence) {
+ if (!outbound.block && suggestParse.cadence) {
const decision = await gateSuggestion({
prisma,
userId,
@@ -614,7 +642,11 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
}
const enrichedProvenance: typeof snapshot.provenance = {
...snapshot.provenance,
- ...(sentinel.keyValues.length > 0 ? { keyValues: sentinel.keyValues } : {}),
+ // v1.18.10 (HIGH-2) — a blocked turn carries the fallback prose, so the
+ // key-values from the discarded reply must not ride along as provenance.
+ ...(!outbound.block && sentinel.keyValues.length > 0
+ ? { keyValues: sentinel.keyValues }
+ : {}),
...(surfacedSuggestion ? { suggestion: surfacedSuggestion } : {}),
};
if (sentinel.malformed) {
@@ -692,9 +724,14 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
// pace).
const stream = createSseStream(async (controller) => {
for (const tok of tokeniseForStreaming(replyText)) {
+ // v1.18.10 (A-2) — stop tokenising the moment the client disconnects;
+ // the cancel handler flips this signal, so we don't pace frames into a
+ // closed connection.
+ if (controller.signal.aborted) return;
controller.enqueue(encodeFrame({ type: "token", token: tok }));
await flushTick();
}
+ if (controller.signal.aborted) return;
controller.enqueue(
encodeFrame({
type: "provenance",
diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts
index 686dfed8e..b979a59d7 100644
--- a/src/app/api/insights/generate/route.ts
+++ b/src/app/api/insights/generate/route.ts
@@ -50,6 +50,11 @@ import {
runRawCompletionWithFallback,
} from "@/lib/ai/provider-runner";
import { assertConsentForChain } from "@/lib/ai/consent-guard";
+import {
+ findUngroundedBriefingNumbers,
+ readBriefingBlock,
+ buildBriefingGroundingCorrection,
+} from "@/lib/ai/briefing-grounding";
import { isLegacyInsightPayload } from "@/lib/ai/legacy-payload";
import { checkRateLimit } from "@/lib/rate-limit";
import { NextRequest } from "next/server";
@@ -729,6 +734,65 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("AI response was not valid JSON", 422);
}
+ // v1.18.10 (HIGH-1) — daily-briefing number-grounding gate, mirroring the
+ // recommendations citation cross-check next door. The schema only checks the
+ // briefing's SHAPE; this asserts every number the briefing prose restates
+ // traces to a `features.signalsOfDay` figure. On a miss: one corrective
+ // retry, then drop the briefing block rather than ship a fabricated number.
+ {
+ const signals = features.signalsOfDay ?? null;
+ let ungrounded = findUngroundedBriefingNumbers(
+ readBriefingBlock(insights),
+ signals,
+ );
+ if (ungrounded.length > 0) {
+ annotate({
+ action: { name: "insights.generate.briefing_grounding_retry" },
+ meta: { ungroundedCount: ungrounded.length },
+ });
+ try {
+ const retry = await runRawCompletionWithFallback({
+ userId,
+ providers: chain,
+ params: {
+ systemPrompt: buildSystemPromptWithReferences(
+ locale,
+ referenceMetrics,
+ ),
+ userPrompt: `${userPrompt}\n\n${buildBriefingGroundingCorrection(ungrounded)}`,
+ temperature: 0.3,
+ maxTokens: 1500,
+ },
+ });
+ const parsedRetry = JSON.parse(retry.result.content);
+ const validatedRetry = insightResultSchema.safeParse(parsedRetry);
+ const retryInsights = validatedRetry.success
+ ? validatedRetry.data
+ : parsedRetry;
+ ungrounded = findUngroundedBriefingNumbers(
+ readBriefingBlock(retryInsights),
+ signals,
+ );
+ if (ungrounded.length === 0) {
+ insights = retryInsights;
+ result = retry.result;
+ workingProviderType = retry.workingProvider.providerType;
+ }
+ } catch {
+ // Retry failed to produce parseable JSON — fall through to the strip.
+ }
+ }
+ // Still ungrounded after the retry: strip the briefing rather than ship a
+ // fabricated figure. The structured recommendations/citations stay.
+ if (ungrounded.length > 0 && insights && typeof insights === "object") {
+ (insights as Record{title}
- )}
- leadingHeaderActions={
- <>
-
- >
- }
- />
- {title}
+ )}
+ />
{t("admin.bugReportGithubDescription")}
++ {t("admin.bugReportDeprecatedNote")} +
+ > + } status={configured ?