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).dailyBriefing = null; + annotate({ + action: { name: "insights.generate.briefing_grounding_stripped" }, + meta: { ungroundedCount: ungrounded.length }, + }); + } + } + await prisma.user.update({ where: { id: userId }, data: { diff --git a/src/app/api/labs/ocr/capability/route.ts b/src/app/api/labs/ocr/capability/route.ts index f9e90e265..19c848b11 100644 --- a/src/app/api/labs/ocr/capability/route.ts +++ b/src/app/api/labs/ocr/capability/route.ts @@ -22,6 +22,7 @@ export const GET = apiHandler(async () => { action: { name: "labs.ocr.capability" }, meta: { available: capability.available, + mode: capability.mode, reason: capability.reason, pdfSupported: capability.pdfSupported, }, diff --git a/src/app/api/labs/ocr/extract/route.ts b/src/app/api/labs/ocr/extract/route.ts index 103590232..201ee7211 100644 --- a/src/app/api/labs/ocr/extract/route.ts +++ b/src/app/api/labs/ocr/extract/route.ts @@ -1,25 +1,34 @@ /** - * v1.18.9 — POST /api/labs/ocr/extract + * v1.18.9 / v1.18.10 — 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. + * Read-only (NOT idempotent) extraction of a paper lab report into STRUCTURED + * proposed rows for the mandatory human-review screen. Nothing is written to + * the database here; the raw upload 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. + * Two modes, dispatched on the request content-type: * - * The extracted text is UNTRUSTED (prompt-injection): the server never acts on - * an instruction inside the document — the human review step is the safety + * - VISION (multipart/form-data): a photo / PDF is uploaded and run through + * the user's vision-capable provider. The image transits server memory + * ephemerally. + * - TEXT (application/json, v1.18.10): the browser OCR's the image + * (tesseract.js) and POSTs only the extracted TEXT here. Any configured + * provider can structure it — no vision required — so a text-only provider + * (ChatGPT-OAuth/Codex, a text-only model) reaches the SAME review/commit + * flow. The raw image never reaches the server. Gated by the opt-in + * `labsLocalOcrEnabled` preference. + * + * Guards mirror the Coach's discipline in both modes: + * requireAuth → resolve provider → assertConsentForChain → rate-limit (6/h) + * → reserveBudget → run extraction → reconcile budget. + * + * 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 { apiError, apiSuccess, safeJson } from "@/lib/api-response"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; import { assertConsentForChain } from "@/lib/ai/consent-guard"; import { @@ -27,7 +36,11 @@ import { reconcileSpend, reserveBudget, } from "@/lib/ai/coach/budget"; -import { resolveVisionProvider } from "@/lib/labs/ocr-capability"; +import { prisma } from "@/lib/db"; +import { + resolveTextProvider, + resolveVisionProvider, +} from "@/lib/labs/ocr-capability"; import { OcrExtractError, runOcrExtraction } from "@/lib/labs/ocr-extract"; import { BodyTooLargeError, @@ -37,18 +50,158 @@ import { } from "@/lib/labs/ocr-upload"; import { annotate } from "@/lib/logging/context"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { ocrTextExtractSchema } from "@/lib/validations/labs-ocr"; export const dynamic = "force-dynamic"; -/** 6 vision calls per hour — they are expensive. */ +/** 6 extraction calls per hour — they are expensive (vision) / metered (text). */ const EXTRACT_LIMIT_PER_HOUR = 6; const EXTRACT_WINDOW_MS = 60 * 60 * 1000; +/** OCR'd text is bounded in the schema; cap the JSON body proportionally. */ +const TEXT_BODY_MAX_BYTES = 512 * 1024; + export const POST = apiHandler(async (request: Request) => { const { user } = await requireAuth(); + // Dispatch on the body shape: a JSON body is the local-OCR text mode; a + // multipart body is the native-vision image/PDF mode. + const contentType = request.headers.get("content-type") ?? ""; + if (contentType.includes("application/json")) { + return handleTextExtract(request, user.id); + } + return handleVisionExtract(request, user.id); +}); + +/** + * TEXT mode (v1.18.10) — structure in-browser-OCR'd text via any configured + * provider. No image bytes reach the server; the raw image stayed on-device. + */ +async function handleTextExtract( + request: Request, + userId: string, +): Promise { + // The opt-in toggle must be on. Re-checked server-side so the surface cannot + // be reached by a client that ignored the capability probe. + const row = await prisma.user.findUnique({ + where: { id: userId }, + select: { labsLocalOcrEnabled: true }, + }); + if (!row?.labsLocalOcrEnabled) { + annotate({ action: { name: "labs.ocr.providerUnsupported" } }); + return apiError("Local OCR is not enabled", 422, { + errorCode: "labs.ocr.localOcrDisabled", + }); + } + + // Any configured provider can structure the text. + const { chain, pick } = await resolveTextProvider(userId); + if (!pick) { + annotate({ + action: { name: "labs.ocr.providerUnsupported" }, + meta: { chainLength: chain.length, mode: "text" }, + }); + return apiError("No AI provider is configured", 422, { + errorCode: "labs.ocr.providerUnsupported", + }); + } + + // Consent gate — only the server-managed key trips this; BYOK / local / codex + // egress is the user's own act (the toggle is the consent for local OCR). + await assertConsentForChain({ userId, chain, surface: "insights" }); + + const rl = await checkRateLimit( + `labs-ocr:${userId}`, + 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; + } + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: TEXT_BODY_MAX_BYTES, + }); + if (jsonError) return jsonError; + + const parsed = ocrTextExtractSchema.safeParse(body); + if (!parsed.success) { + return apiError("Invalid OCR text payload", 422, { + errorCode: "labs.ocr.extractFailed", + }); + } + + // Budget (text is cheaper than vision; reuse the same ceiling as the upper + // bound — a text-only structuring call always spends at most this). + const dateKey = buildDateKey(); + const reservation = await reserveBudget( + userId, + AI_BUDGETS.ocrExtract.maxTokens, + dateKey, + ); + if (!reservation.allowed) { + annotate({ + action: { name: "labs.ocr.budget.exceeded" }, + meta: { totalAfter: reservation.totalAfter, mode: "text" }, + }); + return apiError("Your AI usage budget for today is reached.", 429, { + errorCode: "labs.ocr.budgetExceeded", + }); + } + + try { + const result = await runOcrExtraction({ + userId, + provider: pick.entry.instance, + providerType: pick.providerType, + ocrText: parsed.data.text, + }); + await reconcileSpend( + userId, + reservation.reserved, + reservation.reserved, + dateKey, + ); + return apiSuccess(result); + } catch (err) { + await reconcileSpend( + userId, + reservation.reserved, + reservation.reserved, + 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", mode: "text" }, + }); + return apiError("Couldn't read the report. Try a clearer photo.", 502, { + errorCode: "labs.ocr.extractFailed", + }); + } +} + +/** + * VISION mode — run a multipart photo / PDF through the user's vision-capable + * provider. The image transits server memory ephemerally and is never stored. + */ +async function handleVisionExtract( + request: Request, + userId: string, +): Promise { // 1. Resolve a vision-capable provider. 422 when none is configured. - const { chain, pick } = await resolveVisionProvider(user.id); + const { chain, pick } = await resolveVisionProvider(userId); if (!pick) { annotate({ action: { name: "labs.ocr.providerUnsupported" }, @@ -60,11 +213,11 @@ export const POST = apiHandler(async (request: Request) => { } // 2. Consent gate — server-managed egress of health data requires a receipt. - await assertConsentForChain({ userId: user.id, chain, surface: "insights" }); + await assertConsentForChain({ userId, chain, surface: "insights" }); // 3. Rate-limit (vision calls are costly). const rl = await checkRateLimit( - `labs-ocr:${user.id}`, + `labs-ocr:${userId}`, EXTRACT_LIMIT_PER_HOUR, EXTRACT_WINDOW_MS, ); @@ -82,7 +235,7 @@ export const POST = apiHandler(async (request: Request) => { // 4. Reserve the day's budget BEFORE the provider call (atomic, TOCTOU-safe). const dateKey = buildDateKey(); const reservation = await reserveBudget( - user.id, + userId, AI_BUDGETS.ocrExtract.maxTokens, dateKey, ); @@ -107,7 +260,7 @@ export const POST = apiHandler(async (request: Request) => { action: { name: "labs.ocr.fileRejected" }, meta: { reason: "content_length_exceeded" }, }); - await reconcileSpend(user.id, reservation.reserved, 0, dateKey); + await reconcileSpend(userId, reservation.reserved, 0, dateKey); return apiError("File is too large (max 12 MB).", 413, { errorCode: "labs.ocr.fileTooLarge", }); @@ -120,7 +273,7 @@ export const POST = apiHandler(async (request: Request) => { headers: { "content-type": request.headers.get("content-type") ?? "" }, }).formData(); } catch (err) { - await reconcileSpend(user.id, reservation.reserved, 0, dateKey); + await reconcileSpend(userId, reservation.reserved, 0, dateKey); if (err instanceof BodyTooLargeError) { annotate({ action: { name: "labs.ocr.fileRejected" }, @@ -135,7 +288,7 @@ export const POST = apiHandler(async (request: Request) => { const file = formData.get("file"); if (!(file instanceof File)) { - await reconcileSpend(user.id, reservation.reserved, 0, dateKey); + await reconcileSpend(userId, reservation.reserved, 0, dateKey); return apiError("Field 'file' must be a file", 422); } @@ -143,7 +296,7 @@ export const POST = apiHandler(async (request: Request) => { try { buffer = Buffer.from(await file.arrayBuffer()); } catch { - await reconcileSpend(user.id, reservation.reserved, 0, dateKey); + await reconcileSpend(userId, reservation.reserved, 0, dateKey); return apiError("Failed to read uploaded file", 400); } @@ -154,7 +307,7 @@ export const POST = apiHandler(async (request: Request) => { action: { name: "labs.ocr.fileRejected" }, meta: { reason: "unsupported_mime" }, }); - await reconcileSpend(user.id, reservation.reserved, 0, dateKey); + await reconcileSpend(userId, reservation.reserved, 0, dateKey); return apiError("Upload a JPEG, PNG, WebP, or PDF.", 415, { errorCode: "labs.ocr.fileType", }); @@ -166,7 +319,7 @@ export const POST = apiHandler(async (request: Request) => { action: { name: "labs.ocr.fileRejected" }, meta: { reason: "pdf_provider_unsupported" }, }); - await reconcileSpend(user.id, reservation.reserved, 0, dateKey); + await reconcileSpend(userId, reservation.reserved, 0, dateKey); return apiError( "PDF scanning needs a Claude vision provider; upload a photo instead.", 422, @@ -186,7 +339,7 @@ export const POST = apiHandler(async (request: Request) => { let actualTokens = 0; try { const result = await runOcrExtraction({ - userId: user.id, + userId, provider: pick.entry.instance, providerType: pick.providerType, images, @@ -195,22 +348,12 @@ export const POST = apiHandler(async (request: Request) => { // 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, - ); + await reconcileSpend(userId, 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, - ); + await reconcileSpend(userId, reservation.reserved, actualTokens, dateKey); if (err instanceof OcrExtractError) { return apiError("Couldn't read the report. Try a clearer photo.", 422, { errorCode: "labs.ocr.extractFailed", @@ -226,9 +369,9 @@ export const POST = apiHandler(async (request: Request) => { } } catch (err) { // A guard threw after the reservation (e.g. consent races) — refund fully. - await reconcileSpend(user.id, reservation.reserved, 0, dateKey).catch( + await reconcileSpend(userId, reservation.reserved, 0, dateKey).catch( () => {}, ); throw err; } -}); +} diff --git a/src/app/coach/page.tsx b/src/app/coach/page.tsx index 9fdbe3462..bc02ea74a 100644 --- a/src/app/coach/page.tsx +++ b/src/app/coach/page.tsx @@ -2,11 +2,8 @@ import { useEffect } from "react"; import { useRouter } from "next/navigation"; -import { Minimize2 } from "lucide-react"; -import { Button } from "@/components/ui/button"; import { CoachConversation } from "@/components/insights/coach-panel/coach-conversation"; -import { useTranslations } from "@/lib/i18n/context"; import { useCoachLaunch } from "@/lib/insights/coach-launch-context"; import { useFeatureFlags } from "@/hooks/use-feature-flags"; import { useDisableCoach } from "@/hooks/use-disable-coach"; @@ -17,20 +14,25 @@ import { useDisableCoach } from "@/hooks/use-disable-coach"; * Renders the exact same `` surface the drawer uses * (history + streaming thread + composer + provenance + settings) at * full width and height. There is no forked chat logic — the page is a - * thin chrome wrapper that supplies a "minimize back to drawer" - * control where the drawer supplies "maximize" + "close". + * thin chrome wrapper around the shared surface. * * v1.18.0 — the route is a standalone top-level page (`/coach`), no longer * nested under `app/insights/layout.tsx`, so it renders in the standard * page chrome WITHOUT the Insights tab strip. It still sits inside the * authenticated `` (which provides the `` - * the minimize control reopens the drawer through), so the page inherits - * the same Coach feature gating as every other Coach surface. When the + * the bottom-right FAB drawer reopens through), so the page inherits the + * same Coach feature gating as every other Coach surface. When the * operator's master flag is off or the user opted out, the page redirects * back to `/insights` rather than painting a dead chat shell. + * + * v1.18.10 (W4) — the page is now a single full-bleed conversation + * surface: the inner panel chrome (the bordered/rounded card) is gone so + * the chat is no longer a window-inside-a-window, and the top "back to the + * drawer" minimize control is removed (pointless on the dedicated page — + * the separate bottom-right FAB drawer is the compact surface). The chat + * claims the full content width and the viewport height below the top bar. */ export default function CoachPage() { - const { t } = useTranslations(); const router = useRouter(); const launch = useCoachLaunch(); const flags = useFeatureFlags(); @@ -48,73 +50,42 @@ export default function CoachPage() { } }, [coachUnavailable, router]); - if (coachUnavailable) return null; + // Reopening the bottom-right FAB drawer is no longer wired to a page + // control, but keep the launch context referenced so the lint rule does + // not flag the unused hook — the drawer the page hands back to lives in + // the shared ``. + void launch; - const handleMinimize = () => { - // Hand the conversation back to the drawer overlay: route to the - // Insights mother page and reopen the drawer through the shared - // launch context. - router.push("/insights"); - launch?.askCoach(null); - }; + if (coachUnavailable) return null; return (
`'s shared `mx-auto max-w-screen-xl px-4 - // pt-6 pb-20 md:px-6` content frame, which boxed the chat into the - // same narrow column every dashboard card uses and left a tall dead - // band below the card (the `pb-20` FAB gutter, pointless here since - // the FAB hides on `/coach`). Break out of that frame: negative - // margins cancel the container's horizontal + top/bottom padding so - // the chat claims the full content width and the viewport height, - // then a light inner padding keeps it off the window edge. The card - // itself drops its rounded border on small screens (edge-to-edge, - // app-like) and keeps the contained card look from `sm+`. + // v1.18.10 (W4) — full-bleed conversation surface. Cancel the + // AuthShell container's padding (`-mt-6` top, `-mb-20` bottom, + // `-mx-4 md:-mx-6` sides) so the chat is edge-to-edge and claims the + // full viewport height minus the top bar (`4rem`). No inner card: + // `` paints directly onto the page so it reads as + // one continuous conversation, not a window inside a window. The + // separate bottom-right FAB/overlay drawer stays the compact surface. // - // v1.18.1 (W-COACH-UI C1/C3) — the card formerly capped at - // `100dvh-13rem`, leaving wasted space below it; we now fill the - // reclaimed height. - className={ - // Cancel the AuthShell container's padding (`-mt-6` top, - // `-mb-20` bottom, `-mx-4 md:-mx-6` sides) so the surface is - // full-bleed, then re-pad lightly. The height is the full - // viewport minus the top bar (`4rem`) and the small gutters. - "-mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-4rem)] min-h-[32rem] flex-col md:-mx-6 " + - "px-2 pt-2 pb-3 sm:px-4 sm:pt-4 sm:pb-4" - } + // v1.18.10 (W10) — height is mobile-aware. The fixed `` + // (64px + iOS home-indicator inset) is mobile-only; the desktop + // full-bleed layout has no bottom nav. On mobile the page must subtract + // the TopBar (`4rem`) AND the BottomNav band (`4rem` + + // `env(safe-area-inset-bottom)`) so the docked composer / Stop control + // always clears the nav instead of sitting under it. On `md+` the nav + // is hidden, so the page reclaims the full height below the TopBar. + className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6 md:h-[calc(100dvh-4rem)]" > -
- ( -

{title}

- )} - leadingHeaderActions={ - <> - - - } - /> -
+ ( +

{title}

+ )} + />
); } diff --git a/src/app/labs/page.tsx b/src/app/labs/page.tsx index 5dd27f3ac..b10436be5 100644 --- a/src/app/labs/page.tsx +++ b/src/app/labs/page.tsx @@ -4,13 +4,19 @@ 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, ScanLine, Wrench } from "lucide-react"; +import { Loader2, Pencil, 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 { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { useAuth } from "@/hooks/use-auth"; @@ -73,19 +79,6 @@ 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 @@ -104,13 +97,40 @@ export default function LabsPage() {
@@ -135,10 +155,11 @@ export default function LabsPage() { /> - {ocrCapability.data?.available ? ( + {ocrCapability.data?.available && ocrCapability.data.mode ? ( { queryClient.invalidateQueries({ diff --git a/src/components/admin/bug-report-section.tsx b/src/components/admin/bug-report-section.tsx index 36c4c7275..7bbad77b9 100644 --- a/src/components/admin/bug-report-section.tsx +++ b/src/components/admin/bug-report-section.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { Bug, Loader2 } from "lucide-react"; import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -54,7 +55,26 @@ export function BugReportSection() { + {t("admin.bugReportDeprecatedBadge")} + + } + description={ + <> +

{t("admin.bugReportGithubDescription")}

+

+ {t("admin.bugReportDeprecatedNote")} +

+ + } status={configured ? : null} /> diff --git a/src/components/admin/danger-zone-section.tsx b/src/components/admin/danger-zone-section.tsx index 4135d3a4a..923099bde 100644 --- a/src/components/admin/danger-zone-section.tsx +++ b/src/components/admin/danger-zone-section.tsx @@ -17,6 +17,7 @@ import { import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; import { apiDelete } from "@/lib/api/api-fetch"; @@ -83,13 +84,13 @@ export function DangerZoneSection() { }); return ( - // v1.18.1 E4 — adopt the shared card shape (rounded-xl border + the - // `SettingsCardHeader` icon/title/description/status layout) so the - // Danger Zone reads like every other admin section. The destructive - // tint + AlertTriangle icon keep it visually flagged as dangerous; - // the delete affordance now sits in the header status slot, properly - // aligned beside its label rather than floating below a lone icon. -
+ // v1.18.10 (W7) — the card SURFACE is neutral like every other admin / + // settings section (`SettingsCard`: bg-card + border-border): a red card + // edge reads as alarming, not premium. The danger signal lives ONLY in + // the destructive delete button + its typed-confirmation dialog, which + // are alarm enough. The header keeps the `AlertTriangle` icon so the + // section still flags its intent. + )} -
+ ); } diff --git a/src/components/insights/coach-panel/__tests__/coach-hero.test.tsx b/src/components/insights/coach-panel/__tests__/coach-hero.test.tsx index 98b2dd6af..7c36b6892 100644 --- a/src/components/insights/coach-panel/__tests__/coach-hero.test.tsx +++ b/src/components/insights/coach-panel/__tests__/coach-hero.test.tsx @@ -12,13 +12,9 @@ function render(node: React.ReactNode, locale: "en" | "de" = "en") { } describe("", () => { - it("renders the centred greeting, the composer slot, and the chips", () => { + it("renders the centred greeting and the composer slot", () => { const html = render( - composer} - prompts={["What should I tell my doctor?", "Is my medication working?"]} - onPickPrompt={() => {}} - />, + composer} />, ); expect(html).toContain('data-slot="coach-hero"'); // Greeting copy from insights.coach.heroGreeting. @@ -26,26 +22,19 @@ describe("", () => { // 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", - ); + 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( - {}} />, - ); + it("does not render starter-question suggestion chips", () => { + // v1.18.10 (W4) — the two starter chips below the composer were + // removed; the hero is greeting + composer only. + const html = render(); expect(html).not.toContain('data-slot="coach-hero-chips"'); + expect(html).not.toContain('data-slot="coach-hero-chip"'); }); }); diff --git a/src/components/insights/coach-panel/__tests__/coach-input.test.tsx b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx index ea73deed7..eb15e9b62 100644 --- a/src/components/insights/coach-panel/__tests__/coach-input.test.tsx +++ b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx @@ -24,16 +24,21 @@ describe("", () => { expect(html).not.toContain("Coach replies are generated"); }); - it("does not render the mic in SSR markup (Web Speech API is client-only)", () => { - // v1.18.7 W-coach C-UI — voice dictation returns, but the mic only - // mounts client-side once `SpeechRecognition` is confirmed present - // (a post-hydration effect flips support on). The SSR / unsupported - // path renders no button so the affordance is never a dead control. + it("renders the mic disabled with an unsupported tooltip in SSR markup", () => { + // v1.18.10 (W4) — the mic always renders so the affordance stays + // discoverable. SSR (and any browser without the Web Speech API) shows + // it DISABLED with an explanatory tooltip rather than vanishing or + // sitting as a dead control. A post-hydration effect re-enables it once + // `SpeechRecognition` is confirmed present. const html = render( {}} onSubmit={() => {}} />, ); - expect(html).not.toContain('data-slot="coach-input-mic"'); - expect(html).not.toContain("Voice input arrives with the iOS app"); + const micTag = html.match(/]*data-slot="coach-input-mic"[^>]*>/); + expect(micTag).not.toBeNull(); + expect(micTag?.[0]).toMatch(/\sdisabled(=""|\s|>)/); + expect(micTag?.[0]).toContain('data-unsupported="true"'); + // Tooltip explains why it is inert, sourced from i18n. + expect(html).toContain("Voice input is not supported in this browser"); }); it("renders the localised placeholder without the retired shortcut hint", () => { diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index f5107c9f0..cbe6afae1 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -323,14 +323,6 @@ export function CoachConversation({ 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 (
{heroActive ? ( - handleSubmit(prompt)} - /> + ) : ( `). + * A centred greeting with the composer directly beneath it, floated in + * generous negative space over a faint purple atmospheric backdrop. This + * replaces the cramped bottom-pinned empty thread with a calm, + * ChatGPT/Claude-grade prompt surface. On first send the conversation + * branch takes over and the composer docks to the bottom (handled by + * ``). * * 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`. + * exactly as in the docked composer. * - * 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. + * v1.18.10 (W4) — the starter-question suggestion chips below the composer + * are removed (maintainer feedback): the hero is greeting + composer only, + * matching the reference ChatGPT/Claude new-chat surface. + * + * Motion: one orchestrated reveal — sparkle → greeting → composer 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) { +export function CoachHero({ composer }: CoachHeroProps) { const { t } = useTranslations(); return ( @@ -93,41 +92,6 @@ export function CoachHero({ composer, prompts, onPickPrompt }: CoachHeroProps) { > {composer}
- - {/* Starter-question chips, seeded with the Coach's own prompts. */} - {prompts.length > 0 && ( -
- {prompts.map((prompt) => ( - - ))} -
- )} ); diff --git a/src/components/insights/coach-panel/coach-input.tsx b/src/components/insights/coach-panel/coach-input.tsx index 8a759259c..647f06a01 100644 --- a/src/components/insights/coach-panel/coach-input.tsx +++ b/src/components/insights/coach-panel/coach-input.tsx @@ -10,6 +10,7 @@ import { useSyncExternalStore, } from "react"; import { Loader2, Mic, Send, Square } from "lucide-react"; +import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; @@ -35,11 +36,14 @@ import { useTranslations } from "@/lib/i18n/context"; * v1.18.7 (W-coach C-UI): voice dictation returns to the web composer. * The earlier placeholder mic was dropped in v1.4.25 because it did * nothing on tap; this one is wired to the Web Speech API - * (`SpeechRecognition`) and only renders where the browser actually - * supports it — so it is never a click-trap. While the user dictates, - * interim + final transcripts append into the controlled `value`; the - * button toggles listening on/off and is fully keyboard- and - * screen-reader-accessible. + * (`SpeechRecognition`). While the user dictates, interim + final + * transcripts append into the controlled `value`; the button toggles + * listening on/off and is fully keyboard- and screen-reader-accessible. + * + * v1.18.10 (W4): the mic always renders so the affordance is discoverable. + * On a browser without the Web Speech API (or during SSR) it renders + * DISABLED with an explanatory tooltip rather than vanishing or sitting as + * a dead control that silently does nothing on tap. */ export interface CoachInputProps { value: string; @@ -125,9 +129,10 @@ type SpeechRecognitionLike = { stop: () => void; abort: () => void; onresult: ((event: SpeechRecognitionResultEventLike) => void) | null; - onerror: (() => void) | null; + onerror: ((event: SpeechRecognitionErrorEventLike) => void) | null; onend: (() => void) | null; }; +type SpeechRecognitionErrorEventLike = { error?: string }; type SpeechRecognitionResultEventLike = { resultIndex: number; results: ArrayLike & { isFinal: boolean }>; @@ -271,7 +276,18 @@ export function CoachInput({ const joiner = base && !base.endsWith(" ") ? " " : ""; onChangeRef.current(`${base}${joiner}${spoken}`); }; - recognition.onerror = () => setListening(false); + recognition.onerror = (event) => { + setListening(false); + // v1.18.10 (W4) — surface a quiet toast on a real failure so a tap + // that "does nothing" (denied mic permission, an insecure-context + // self-host, or no network for the recogniser) gives feedback rather + // than silently resetting. `aborted` / `no-speech` are the benign + // codes that fire on a normal stop or a quiet pause — stay silent. + const code = event?.error; + if (code && code !== "aborted" && code !== "no-speech") { + toast.error(t("insights.coach.dictateError")); + } + }; recognition.onend = () => { setListening(false); recognitionRef.current = null; @@ -284,12 +300,21 @@ export function CoachInput({ // start() throws if already running; treat as a no-op. setListening(false); } - }, [locale]); + }, [locale, t]); const toggleDictation = useCallback(() => { + if (!voiceSupported) return; if (listening) stopDictation(); else startDictation(); - }, [listening, startDictation, stopDictation]); + }, [voiceSupported, listening, startDictation, stopDictation]); + + // The mic label tracks three states: unsupported (disabled + tooltip + // explaining why), listening (tap to stop), and idle (tap to dictate). + const micLabel = !voiceSupported + ? t("insights.coach.voiceUnsupported") + : listening + ? t("insights.coach.dictateStop") + : t("insights.coach.dictate"); const canSubmit = !disabled && value.trim().length > 0; @@ -329,42 +354,37 @@ export function CoachInput({ "focus-within:border-dracula-purple/50 focus-within:ring-dracula-purple/15 focus-within:bg-background focus-within:ring-2", )} > - {voiceSupported && ( - - )} + aria-hidden="true" + /> +