diff --git a/CHANGELOG.md b/CHANGELOG.md index e2f98ebbb..520c70801 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,134 @@ ## [Unreleased] +## [1.47.3] — 2026-11-12 + +### Changed + +- **AI uses only the providers you left switched on.** When the provider + order saved under Settings > AI could not answer (say only a local + model was on and it was unreachable), several features quietly fell + back to the ChatGPT sign-in or to the operator's server key: the + briefing, the status notes, document reading and the Coach among + them. They now report that no provider is available, like the rest of + the app already did, and the cost lands where you expect it. An + account that never saved a provider order works as before, and the + OpenAI-compatible gateway you picked still answers unless you switched + it off in the order. If you run an instance and your people rely on + the server key, have them keep it switched on in their provider order. + The connection test now says "No AI provider configured" in that case + instead of a confusing validation message. +- **Turning AI off also ends the invitations to set it up.** With both + the Coach and AI analysis switched off, the "set up AI" hint, the + checklist row and the mention on the last setup screen no longer + appear. That setup screen now carries one line pointing to + Settings > AI instead of a large panel. +- **Blood pressure and resting pulse are judged against your own + target.** The charts have followed a target you set since #1261, but + the status notes, the overview, the morning briefing and the Coach + still compared your readings with the age standard. They now use your + target when you set one, and the age standard otherwise. +- **Coach plans show their metric by name.** A plan card read a raw key + such as `WEIGHT`; it now names the measurement in your language. The + nightly review still moves a finished plan to "Reviewed", but no + longer works out a verdict that no screen showed. The + `COACH_EXPERIMENT_VERDICT` setting is gone; remove it from your + environment if you set it. +- **The notification settings list only what HealthLog sends.** + "Unusual measurement", "Low adherence", "Withings sync failed" and + "Personal record" had switches, three of them on by default, but + nothing ever sent them. They are gone from the list. A failed Withings + sync still reaches admins as a system alert. + +### Fixed + +- **New entries show up in the day view and on the timeline right + away.** Symptoms, workouts, lab results, allergies, documents and + questionnaires appeared there only after a reload, also when added + through "Capture for this day". Confirming facts from a document and + turning free-text conditions or allergies into entries now update the + dashboard, Today, the day view and the timeline at once, and a + check-up marked done leaves the Today rail immediately. +- **Forms close as soon as the entry is stored.** Saving a measurement, + a mood, a workout, a dose, a vaccination, a visit, a symptom, an + illness episode or a life event kept the sheet open with emptied + fields and a spinner until every chart had reloaded. It now closes + right away and the charts catch up behind it. Bulk deletes and their + Undo do the same. +- **Tapping Save again after a timeout no longer stores the entry + twice.** Each opened form now sends the same request key until it is + saved, so the server recognises a repeat. +- **Errors say what went wrong, in your language.** An expired session, + a rate limit, a missing permission or a rejected value now say so + instead of "could not be saved", and many places that showed + the server's English text, among them the settings, mood tags, + medications, the Coach model picker and the admin pages, now show a + translated sentence. The measurement edit sheet marks an implausible + value on its field and names the accepted range in your unit. The + visit, vaccination, allergy, address-book and workout forms name the + actual problem, such as a removed doctor or a dose without a name. A + mistyped password while adding a passkey says so, instead of claiming + the session had ended. +- **Undo after deleting a vaccination, a visit or an address-book + entry.** The toast now offers it, like it does for illness episodes, + also when you delete two entries in a row. +- **A check-up marked done cannot be marked twice.** A quick second tap + on "Done", "Skip" or "Snooze" stored a second entry. +- **A managed person's health record carries their name.** The PDF, + its properties, the FHIR export and the share link showed the + technical login `managed-…` when no full name was stored. They now use + the display name, and so does an own account's report when no full + name is set. Initials in settings and in the Coach chat follow the + display name too. +- **Dates without a time land on their own day west of UTC.** For + people in the Americas the date of birth on the health record and the + share page showed the day before, and vaccinations sat on the previous + day in the day view, the calendar dots, the timeline, the visit + preparation, the health record and the share page; the first booster + reminder came a day early, also when a logged dose moved an existing + booster reminder. All of them now read the date as a date, and a date + of birth entered in the iPhone app is stored as a plain date too, so + it no longer depends on the hour it was typed. + The day view also stops inventing a clock time (01:00 or 13:00) for + vaccinations and allergy onsets. +- **Weight and BMI in someone else's record use that record.** In a + shared or managed record, the weight chart shaded the viewer's target + or a band from the viewer's height, the BMI page and chart checked the + viewer's height, and the target gear opened an editor that could not + save there. They now use the record on screen, and the gear is hidden + where it cannot work. Someone with read access to a record now sees + its weight band as well. The adult weight and BMI bands are no longer + drawn on a child's record, since they do not apply to children; the + values and charts stay. A weight target below 30 kg is accepted, so a + guardian can set one for a child. +- **"What changed since your last panel" sees small changes.** A move + from 0.004 to 0.009 read "no change", and some values printed with + fifteen decimals. Changes are now compared at the precision the lab + printed and shown in your language's notation. +- **The Medications module switched off means quiet.** Dose reminders, + missed-dose entries, low-stock alerts and the Telegram medication + commands kept running with the module off. They now wait until it is + switched back on, and nothing is lost meanwhile. A medication whose + course has ended no longer triggers low-stock alerts, and the Telegram + bot no longer offers it or records a dose for it. When two medications + share a name, the bot now picks the running one. +- **Medication reminders show the time in your 12- or 24-hour + setting.** +- **Computed scores no longer offer edit and delete.** Recovery, strain + and other computed rows in the measurement list showed a pencil that + led to an empty form and a bin, and "select all" swept them into a bulk + delete. They are now left out of both. +- **The cycle page takes "today" from your profile's time zone**, like + the rest of the app, instead of from the device. +- **The document pickers say when they show only the newest 50**, and + that search finds older ones. + +### Removed + +- The disabled "Try a 7-day experiment" button on correlation cards, + which never did anything. +- Three components nothing rendered, and their strings. + ## [1.47.2] — 2026-11-11 ### Added diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 7e17a0f95..7cab48dfe 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.47.2 + version: 1.47.3 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -13698,9 +13698,9 @@ paths: and the profile facts every band was resolved from. Pure compute over the record's own data — no provider anywhere on the path. Stale-while-revalidate: past the fresh window the prior body serves immediately while one background rebuild warms the cell, and a write hard-evicts the bucket so the account's own action lands on the - next read. Delegable at MANAGE level over the whole record: the walk spans vitals, sleep, medications, mood and - glucose, so a section-scoped grant is refused rather than served a filtered page. Not module-gated as a whole; a - tile whose data does not exist is simply absent. Cookie or Bearer auth." + next read. Delegable at READ level over the whole record, like the dashboard snapshot: the walk spans vitals, + sleep, medications, mood and glucose, so a section-scoped grant is refused rather than served a filtered page. + Not module-gated as a whole; a tile whose data does not exist is simply absent. Cookie or Bearer auth." responses: "200": description: The target tiles and their page summary. @@ -39722,8 +39722,8 @@ components: anamnesis.auditC.invalid, anamnesis.auditC.notFound, anamnesis.auditC.rateLimited, anamnesis.fact.conflict, anamnesis.fact.currentExists, anamnesis.fact.invalidValue, anamnesis.lifeConfirm.invalid, anamnesis.lifeSections.invalid; auth — auth.account.deletion_pending, - auth.admin.required, auth.already_authenticated, auth.mfa.code_invalid, auth.missing, - auth.passkey.last_method, auth.refresh.invalid, auth.refresh.reuse, auth.refresh.revoked, + auth.admin.required, auth.already_authenticated, auth.mfa.already_active, auth.mfa.code_invalid, + auth.missing, auth.passkey.last_method, auth.refresh.invalid, auth.refresh.reuse, auth.refresh.revoked, auth.reproof.failed, auth.reproof.required, auth.reproof.too_weak, auth.scope.insufficient, auth.session.insecure_transport, auth.session.unavailable, auth.stepup.required, auth.token.expired, auth.token.invalid; backup — backup.foreign_reference, backup.key.missing, backup.owner.exists, @@ -39736,30 +39736,30 @@ components: coach.fenced.attachmentConflict, coach.fenced.attachmentLimit, coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed, coach.fenced.attachmentRequired, coach.fenced.attachmentUnavailable, coach.fenced.invalid, coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json, - coach-prefs.body.invalid_shape; consent — consent.ai.confirmRequired, consent.ai.required; cycle — - cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid, cycle.calendar.range, - cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled, - cycle.feed.api_disabled, cycle.feed.cookie_only, cycle.feed.exists, cycle.feed.fertile_goal, - cycle.feed.none, cycle.feed.notFound, cycle.feed.rateLimited, cycle.period.invalid, - cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs — cycle-prefs.body.invalid_json, - cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json; data_wipe — data_wipe.copy_failed; - dawarich — dawarich.credentials_rejected, dawarich.home_place_unknown, dawarich.invalid, - dawarich.not_connected, dawarich.rate_limited, dawarich.unreachable; day — day.invalid; demo — - demo.readOnly; documents — documents.chat.conversationNotFound, documents.chat.invalid, - documents.inbound.alreadyConfirmed, documents.inbound.alreadyPartlyConfirmed, - documents.inbound.budgetExceeded, documents.inbound.decryptFailed, documents.inbound.encounterNotFound, - documents.inbound.episodeNotFound, documents.inbound.extractFailed, documents.inbound.factNotFound, - documents.inbound.factNotPending, documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, - documents.inbound.fileType, documents.inbound.invalidMetadata, documents.inbound.invalidQuery, - documents.inbound.invalidUpdate, documents.inbound.localOcrDisabled, documents.inbound.notDeleted, - documents.inbound.notFound, documents.inbound.notIndexed, documents.inbound.outputLimit, - documents.inbound.pdfRendererUnavailable, documents.inbound.providerUnsupported, - documents.inbound.quotaExceeded, documents.inbound.rateLimited, documents.inbound.restoreDuplicate, - documents.inbound.restoreGone, documents.inbound.sourceAliasLimit, documents.inbound.uploadBusy, - documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound, documents.sources.authRefused, - documents.sources.badResponse, documents.sources.browserOnly, documents.sources.invalidAddress, - documents.sources.linkTargetNotFound, documents.sources.notConnected, documents.sources.notFound, - documents.sources.organizationRequired, documents.sources.originNotAllowed, + coach-prefs.body.invalid_shape; consent — consent.ai.confirmRequired, consent.ai.required; customMetric + — customMetric.name_taken; cycle — cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid, + cycle.calendar.range, cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid, + cycle.disabled, cycle.feed.api_disabled, cycle.feed.cookie_only, cycle.feed.exists, + cycle.feed.fertile_goal, cycle.feed.none, cycle.feed.notFound, cycle.feed.rateLimited, + cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs — + cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json; + data_wipe — data_wipe.copy_failed; dawarich — dawarich.credentials_rejected, + dawarich.home_place_unknown, dawarich.invalid, dawarich.not_connected, dawarich.rate_limited, + dawarich.unreachable; day — day.invalid; demo — demo.readOnly; documents — + documents.chat.conversationNotFound, documents.chat.invalid, documents.inbound.alreadyConfirmed, + documents.inbound.alreadyPartlyConfirmed, documents.inbound.budgetExceeded, + documents.inbound.decryptFailed, documents.inbound.encounterNotFound, documents.inbound.episodeNotFound, + documents.inbound.extractFailed, documents.inbound.factNotFound, documents.inbound.factNotPending, + documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, documents.inbound.fileType, + documents.inbound.invalidMetadata, documents.inbound.invalidQuery, documents.inbound.invalidUpdate, + documents.inbound.localOcrDisabled, documents.inbound.notDeleted, documents.inbound.notFound, + documents.inbound.notIndexed, documents.inbound.outputLimit, documents.inbound.pdfRendererUnavailable, + documents.inbound.providerUnsupported, documents.inbound.quotaExceeded, documents.inbound.rateLimited, + documents.inbound.restoreDuplicate, documents.inbound.restoreGone, documents.inbound.sourceAliasLimit, + documents.inbound.uploadBusy, documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound, + documents.sources.authRefused, documents.sources.badResponse, documents.sources.browserOnly, + documents.sources.invalidAddress, documents.sources.linkTargetNotFound, documents.sources.notConnected, + documents.sources.notFound, documents.sources.organizationRequired, documents.sources.originNotAllowed, documents.sources.permissionMissing, documents.sources.rateLimited, documents.sources.redirected, documents.sources.tokenRequired, documents.sources.unavailable, documents.sources.unreachable, documents.sources.versionTooOld; encounter — encounter.invalid, encounter.link.invalid, @@ -90687,8 +90687,8 @@ components: type: array items: type: string - description: The event categories this account is shown a switch for, in declaration order — the list a client renders - rows for. A category nothing sends is not listed, and `preferences` carries no stored row for it. + description: "The event categories this account is shown a switch for, in declaration order: the list a client renders + rows for. A category nothing sends is not listed, and `preferences` carries no stored row for it." eventDefaults: type: object propertyNames: diff --git a/messages/de.json b/messages/de.json index 735e7e288..b7fdacfce 100644 --- a/messages/de.json +++ b/messages/de.json @@ -142,11 +142,15 @@ "auth": { "reproof": { "required": "Bestätige dein aktuelles Passwort, um das zu ändern.", - "failed": "Die Bestätigung ist fehlgeschlagen. Es wurde nichts geändert." + "failed": "Die Bestätigung ist fehlgeschlagen. Es wurde nichts geändert.", + "too_weak": "Bestätige mit deinem zweiten Faktor oder einem Passkey. Bei diesem Konto reicht das Passwort allein nicht." }, "missing": "Deine Sitzung ist abgelaufen, daher wurde nichts geändert. Melde dich erneut an, um weiterzumachen.", "passkey": { "last_method": "Dieser Passkey ist deine einzige Anmeldemöglichkeit. Lege zuerst ein Passwort fest oder füge einen weiteren Passkey hinzu." + }, + "mfa": { + "already_active": "Für dieses Konto ist bereits eine Authenticator-App eingerichtet. Deaktiviere sie zuerst, um eine neue einzurichten." } }, "biomarkers": { @@ -218,10 +222,18 @@ "workout": { "batch": { "invalid": "Prüfe die Angaben zum Training und versuche es erneut.", - "too_large": "Zu viele Trainings auf einmal. Schicke weniger auf einmal.", + "too_large": "Zu viele Trainings auf einmal. Schicke sie in kleineren Paketen.", "payload_too_large": "Die Trainingsdaten sind zu groß, um sie auf einmal zu speichern.", "source_not_permitted": "Diese Verbindung darf keine Trainings unter dieser Quelle speichern." } + }, + "ai": { + "provider": { + "none": "Es ist kein KI-Anbieter eingerichtet, daher wurde nichts gesendet. Richte einen in den KI-Einstellungen ein." + }, + "budget": { + "exceeded": "Das KI-Budget für heute ist aufgebraucht, daher wurde nichts gesendet. Morgen steht es wieder bereit." + } } }, "doctorReport": { @@ -1024,7 +1036,8 @@ "typeTrainingReadiness": "Trainingsbereitschaft", "typeAcuteTrainingLoad": "Akute Trainingsbelastung", "sourceGarmin": "Garmin", - "valueOutOfRange": "Gib einen Wert zwischen {min} und {max} {unit} ein." + "valueOutOfRange": "Gib einen Wert zwischen {min} und {max} {unit} ein.", + "valueOutOfRangeNoUnit": "Gib einen Wert zwischen {min} und {max} ein." }, "metricCatalog": { "title": "Was HealthLog erfassen kann", @@ -5959,6 +5972,7 @@ "inProgress": "Ein Import des Einnahmeverlaufs läuft bereits. Auf sein Ende warten.", "workerUnavailable": "Der Hintergrunddienst ist nicht verfügbar, es wurde nichts importiert.", "failed": "Der Import ist fehlgeschlagen. Es wurde nichts geschrieben.", + "stillRunning": "Der Import dauert länger als üblich und kann noch fertig werden. Sieh dir deine Einnahmen in ein paar Minuten noch einmal an.", "columnsHeading": "Was mit jeder Spalte passiert", "jsonOnly": "nur JSON", "previewLine": "{rows} Zeilen gelesen, {queued} würden importiert, {refused} abgewiesen", @@ -12494,7 +12508,8 @@ "purgeConfirmBody": "Es wird jetzt entfernt statt am Ende der Wartezeit und lässt sich nicht wiederherstellen." }, "picker": { - "capped": "Hier stehen die neuesten {count}. Ältere Dokumente findest du über die Suche." + "capped": "Hier stehen die neuesten {count}. Ältere Dokumente findest du über die Suche.", + "cappedSearching": "Hier stehen die neuesten {count} Treffer. Ältere findest du mit einer genaueren Suche." } }, "selfDescription": { diff --git a/messages/en.json b/messages/en.json index b20c38494..39c485e8f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -142,11 +142,15 @@ "auth": { "reproof": { "required": "Confirm your current password to change this.", - "failed": "That did not verify. Nothing was changed." + "failed": "That did not verify. Nothing was changed.", + "too_weak": "Confirm with your second factor or a passkey. On this account a password alone is not enough." }, "missing": "Your session has ended, so nothing was changed. Sign in again to continue.", "passkey": { "last_method": "This passkey is your only way to sign in. Set a password or add another passkey first." + }, + "mfa": { + "already_active": "An authenticator app is already set up for this account. Disable it first to set up a new one." } }, "biomarkers": { @@ -204,7 +208,7 @@ "encounter-not-found": "This visit no longer exists. Choose another or none.", "custom-vaccine-not-found": "This vaccine no longer exists. Choose it again from the list.", "not-found": "This vaccination is no longer available.", - "identity-required": "A dose needs a name: pick a vaccine from the list or type what the record says.", + "identity-required": "A dose needs a name: pick a vaccine from the list or type the name on your vaccination card.", "booster-invalid": "Check the booster details and try again.", "booster-no-antigen": "This dose names no vaccine from the catalogue, so no booster can be planned for it.", "booster-out-of-scope": "Your access to this record does not cover planning a booster." @@ -218,10 +222,18 @@ "workout": { "batch": { "invalid": "Check the workout details and try again.", - "too_large": "Too many workouts at once. Send fewer at a time.", + "too_large": "Too many workouts at once. Send them in smaller batches.", "payload_too_large": "The workout data is too large to save in one go.", "source_not_permitted": "This connection may not save workouts under that source." } + }, + "ai": { + "provider": { + "none": "No AI provider is set up, so nothing was sent to one. Set one up in the AI settings." + }, + "budget": { + "exceeded": "Today's AI budget is used up, so nothing was sent. It renews tomorrow." + } } }, "doctorReport": { @@ -1024,7 +1036,8 @@ "typeTrainingReadiness": "Training readiness", "typeAcuteTrainingLoad": "Acute training load", "sourceGarmin": "Garmin", - "valueOutOfRange": "Enter a value between {min} and {max} {unit}." + "valueOutOfRange": "Enter a value between {min} and {max} {unit}.", + "valueOutOfRangeNoUnit": "Enter a value between {min} and {max}." }, "metricCatalog": { "title": "What HealthLog can track", @@ -5959,6 +5972,7 @@ "inProgress": "A dose-history import is already running. Wait for it to finish.", "workerUnavailable": "The background worker is not available, so nothing was imported.", "failed": "The import failed. Nothing was written.", + "stillRunning": "The import is taking longer than usual and may still finish. Look at your doses again in a few minutes.", "columnsHeading": "What each column does", "jsonOnly": "JSON only", "previewLine": "{rows} rows read, {queued} would be imported, {refused} refused", @@ -12494,7 +12508,8 @@ "purgeConfirmBody": "It is removed now instead of at the end of the waiting period, and cannot be restored." }, "picker": { - "capped": "Showing the newest {count}. Search to find an older document." + "capped": "Showing the newest {count}. Search to find an older document.", + "cappedSearching": "Showing the newest {count} matches. Narrow the search to find an older one." } }, "selfDescription": { diff --git a/messages/es.json b/messages/es.json index 26b179ae2..ce7e7417b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -142,11 +142,15 @@ "auth": { "reproof": { "required": "Confirma tu contraseña actual para cambiar esto.", - "failed": "La verificación ha fallado. No se ha cambiado nada." + "failed": "La verificación ha fallado. No se ha cambiado nada.", + "too_weak": "Confírmalo con tu segundo factor o una clave de acceso. En esta cuenta no basta con la contraseña." }, "missing": "Tu sesión ha caducado, así que no se ha cambiado nada. Inicia sesión de nuevo para continuar.", "passkey": { "last_method": "Esta clave de acceso es tu única forma de iniciar sesión. Primero establece una contraseña o añade otra clave de acceso." + }, + "mfa": { + "already_active": "Esta cuenta ya tiene una aplicación de autenticación configurada. Desactívala primero para configurar una nueva." } }, "biomarkers": { @@ -204,7 +208,7 @@ "encounter-not-found": "Esta cita ya no existe. Elige otra o ninguna.", "custom-vaccine-not-found": "Esta vacuna ya no existe. Elígela de nuevo en la lista.", "not-found": "Esta vacuna ya no está disponible.", - "identity-required": "Una dosis necesita un nombre: elige una vacuna de la lista o escribe lo que pone el registro.", + "identity-required": "Una dosis necesita un nombre: elige una vacuna de la lista o escribe lo que figura en la cartilla.", "booster-invalid": "Comprueba los datos del refuerzo e inténtalo de nuevo.", "booster-no-antigen": "Esta dosis no indica ninguna vacuna del catálogo, así que no se puede planificar un refuerzo.", "booster-out-of-scope": "Tu acceso a este registro no permite planificar un refuerzo." @@ -222,6 +226,14 @@ "payload_too_large": "Los datos del entrenamiento son demasiado grandes para guardarlos de una vez.", "source_not_permitted": "Esta conexión no puede guardar entrenamientos con esa fuente." } + }, + "ai": { + "provider": { + "none": "No hay ningún proveedor de IA configurado, así que no se ha enviado nada. Configura uno en los ajustes de IA." + }, + "budget": { + "exceeded": "El presupuesto de IA de hoy se ha agotado, así que no se ha enviado nada. Se renueva mañana." + } } }, "doctorReport": { @@ -1024,7 +1036,8 @@ "typeTrainingReadiness": "Preparación para entrenar", "typeAcuteTrainingLoad": "Carga de entrenamiento aguda", "sourceGarmin": "Garmin", - "valueOutOfRange": "Introduce un valor entre {min} y {max} {unit}." + "valueOutOfRange": "Introduce un valor entre {min} y {max} {unit}.", + "valueOutOfRangeNoUnit": "Introduce un valor entre {min} y {max}." }, "metricCatalog": { "title": "Lo que HealthLog puede registrar", @@ -5959,6 +5972,7 @@ "inProgress": "Ya hay una importación de historial en curso. Espera a que termine.", "workerUnavailable": "El proceso en segundo plano no está disponible, así que no se importó nada.", "failed": "La importación falló. No se escribió nada.", + "stillRunning": "La importación está tardando más de lo habitual y aún puede terminar. Vuelve a mirar tus dosis dentro de unos minutos.", "columnsHeading": "Qué hace cada columna", "jsonOnly": "solo JSON", "previewLine": "{rows} filas leídas, {queued} se importarían, {refused} rechazadas", @@ -12494,7 +12508,8 @@ "purgeConfirmBody": "Se elimina ahora en lugar de al final del periodo de espera y no se puede restaurar." }, "picker": { - "capped": "Se muestran los {count} más recientes. Usa la búsqueda para encontrar un documento más antiguo." + "capped": "Se muestran los {count} más recientes. Usa la búsqueda para encontrar un documento más antiguo.", + "cappedSearching": "Se muestran las {count} coincidencias más recientes. Afina la búsqueda para encontrar una más antigua." } }, "selfDescription": { diff --git a/messages/fr.json b/messages/fr.json index c37e70b49..b6caaeb86 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -142,11 +142,15 @@ "auth": { "reproof": { "required": "Confirmez votre mot de passe actuel pour modifier ceci.", - "failed": "La vérification a échoué. Rien n'a été modifié." + "failed": "La vérification a échoué. Rien n'a été modifié.", + "too_weak": "Confirmez avec votre second facteur ou une clé d’accès. Sur ce compte, le mot de passe seul ne suffit pas." }, "missing": "Votre session a expiré, rien n'a donc été modifié. Reconnectez-vous pour continuer.", "passkey": { "last_method": "Cette clé d’accès est votre seul moyen de connexion. Définissez d’abord un mot de passe ou ajoutez une autre clé d’accès." + }, + "mfa": { + "already_active": "Une application d'authentification est déjà configurée pour ce compte. Désactivez-la d'abord pour en configurer une nouvelle." } }, "biomarkers": { @@ -218,10 +222,18 @@ "workout": { "batch": { "invalid": "Vérifiez les informations de l'entraînement et réessayez.", - "too_large": "Trop d'entraînements à la fois. Envoyez-en moins à la fois.", + "too_large": "Trop d'entraînements à la fois. Envoyez-les par lots plus petits.", "payload_too_large": "Les données d'entraînement sont trop volumineuses pour être enregistrées en une fois.", "source_not_permitted": "Cette connexion n'a pas le droit d'enregistrer des entraînements sous cette source." } + }, + "ai": { + "provider": { + "none": "Aucun fournisseur d’IA n’est configuré, rien n’a donc été envoyé. Configurez-en un dans les réglages IA." + }, + "budget": { + "exceeded": "Le budget IA du jour est épuisé, rien n’a donc été envoyé. Il se renouvelle demain." + } } }, "doctorReport": { @@ -1024,7 +1036,8 @@ "typeTrainingReadiness": "Disponibilité à l'entraînement", "typeAcuteTrainingLoad": "Charge d'entraînement aiguë", "sourceGarmin": "Garmin", - "valueOutOfRange": "Saisissez une valeur entre {min} et {max} {unit}." + "valueOutOfRange": "Saisissez une valeur entre {min} et {max} {unit}.", + "valueOutOfRangeNoUnit": "Saisissez une valeur entre {min} et {max}." }, "metricCatalog": { "title": "Ce que HealthLog peut suivre", @@ -5959,6 +5972,7 @@ "inProgress": "Un import d'historique est déjà en cours. Attendez qu'il se termine.", "workerUnavailable": "Le service d'arrière-plan est indisponible, rien n'a été importé.", "failed": "L'import a échoué. Rien n'a été écrit.", + "stillRunning": "L'import prend plus de temps que d'habitude et peut encore aboutir. Revenez voir vos prises dans quelques minutes.", "columnsHeading": "Ce que devient chaque colonne", "jsonOnly": "JSON uniquement", "previewLine": "{rows} lignes lues, {queued} seraient importées, {refused} refusées", @@ -12494,7 +12508,8 @@ "purgeConfirmBody": "Il est supprimé maintenant plutôt qu'à la fin du délai d'attente et ne peut pas être restauré." }, "picker": { - "capped": "Les {count} plus récents sont affichés. Utilisez la recherche pour trouver un document plus ancien." + "capped": "Les {count} plus récents sont affichés. Utilisez la recherche pour trouver un document plus ancien.", + "cappedSearching": "Les {count} résultats les plus récents sont affichés. Affinez la recherche pour en trouver un plus ancien." } }, "selfDescription": { diff --git a/messages/it.json b/messages/it.json index e18251ca4..177023cb8 100644 --- a/messages/it.json +++ b/messages/it.json @@ -142,11 +142,15 @@ "auth": { "reproof": { "required": "Conferma la password attuale per modificarlo.", - "failed": "La verifica non è riuscita. Non è stato modificato nulla." + "failed": "La verifica non è riuscita. Non è stato modificato nulla.", + "too_weak": "Conferma con il secondo fattore o una passkey. Per questo account la sola password non basta." }, "missing": "La tua sessione è scaduta, quindi non è stato modificato nulla. Accedi di nuovo per continuare.", "passkey": { "last_method": "Questa passkey è il tuo unico modo per accedere. Imposta prima una password o aggiungi un’altra passkey." + }, + "mfa": { + "already_active": "Per questo account è già configurata un'app di autenticazione. Disattivala prima di configurarne una nuova." } }, "biomarkers": { @@ -218,10 +222,18 @@ "workout": { "batch": { "invalid": "Controlla i dati dell'allenamento e riprova.", - "too_large": "Troppi allenamenti in una volta. Inviane meno alla volta.", + "too_large": "Troppi allenamenti in una volta. Inviali in gruppi più piccoli.", "payload_too_large": "I dati dell'allenamento sono troppo grandi per salvarli in una volta.", "source_not_permitted": "Questa connessione non può salvare allenamenti con questa fonte." } + }, + "ai": { + "provider": { + "none": "Nessun provider di IA è configurato, quindi non è stato inviato nulla. Configurane uno nelle impostazioni IA." + }, + "budget": { + "exceeded": "Il budget IA di oggi è esaurito, quindi non è stato inviato nulla. Si rinnova domani." + } } }, "doctorReport": { @@ -1024,7 +1036,8 @@ "typeTrainingReadiness": "Prontezza all'allenamento", "typeAcuteTrainingLoad": "Carico di allenamento acuto", "sourceGarmin": "Garmin", - "valueOutOfRange": "Inserisci un valore tra {min} e {max} {unit}." + "valueOutOfRange": "Inserisci un valore tra {min} e {max} {unit}.", + "valueOutOfRangeNoUnit": "Inserisci un valore tra {min} e {max}." }, "metricCatalog": { "title": "Cosa può monitorare HealthLog", @@ -5959,6 +5972,7 @@ "inProgress": "Un'importazione dello storico è già in corso. Attendi che finisca.", "workerUnavailable": "Il processo in background non è disponibile, quindi non è stato importato nulla.", "failed": "L'importazione è fallita. Non è stato scritto nulla.", + "stillRunning": "L'importazione richiede più tempo del solito e potrebbe ancora completarsi. Ricontrolla le tue assunzioni tra qualche minuto.", "columnsHeading": "Cosa fa ogni colonna", "jsonOnly": "solo JSON", "previewLine": "{rows} righe lette, {queued} da importare, {refused} rifiutate", @@ -12494,7 +12508,8 @@ "purgeConfirmBody": "Viene rimosso ora anziché alla fine del periodo di attesa e non può essere ripristinato." }, "picker": { - "capped": "Sono mostrati i {count} più recenti. Usa la ricerca per trovare un documento più vecchio." + "capped": "Sono mostrati i {count} più recenti. Usa la ricerca per trovare un documento più vecchio.", + "cappedSearching": "Sono mostrati i {count} risultati più recenti. Restringi la ricerca per trovarne uno più vecchio." } }, "selfDescription": { diff --git a/messages/ko.json b/messages/ko.json index 84ad9f739..a02fd7949 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -142,11 +142,15 @@ "auth": { "reproof": { "required": "변경하려면 현재 비밀번호를 확인하세요.", - "failed": "확인에 실패했습니다. 아무것도 변경되지 않았습니다." + "failed": "확인에 실패했습니다. 아무것도 변경되지 않았습니다.", + "too_weak": "2단계 인증 수단이나 패스키로 확인하세요. 이 계정에서는 비밀번호만으로는 충분하지 않아요." }, "missing": "세션이 만료되어 아무것도 변경되지 않았어요. 계속하려면 다시 로그인해 주세요.", "passkey": { "last_method": "이 패스키가 유일한 로그인 수단이에요. 먼저 비밀번호를 설정하거나 패스키를 하나 더 추가하세요." + }, + "mfa": { + "already_active": "이 계정에는 이미 인증 앱이 설정되어 있어요. 새로 설정하려면 먼저 해제하세요." } }, "biomarkers": { @@ -194,14 +198,14 @@ }, "encounter": { "invalid": "진료 정보를 확인하고 다시 시도해 주세요.", - "practitioner-not-found": "이 병원 또는 의사가 더 이상 없어요. 다른 항목을 고르거나 선택하지 마세요.", - "reminder-not-found": "이 건강검진이 더 이상 없어요. 다른 검진을 고르거나 선택하지 마세요.", + "practitioner-not-found": "이 병원 또는 의사가 더 이상 없어요. 다른 항목을 고르거나 '지정 안 함'을 선택해 주세요.", + "reminder-not-found": "이 건강검진이 더 이상 없어요. 다른 검진을 고르거나 선택을 비워 두세요.", "reminder-conflict": "예정된 진료는 아직 이루어지지 않았기 때문에 건강검진을 완료할 수 없어요." }, "vaccination": { "invalid": "예방접종 정보를 확인하고 다시 시도해 주세요.", - "practitioner-not-found": "이 병원 또는 의사가 더 이상 없어요. 다른 항목을 고르거나 선택하지 마세요.", - "encounter-not-found": "이 진료가 더 이상 없어요. 다른 진료를 고르거나 선택하지 마세요.", + "practitioner-not-found": "이 병원 또는 의사가 더 이상 없어요. 다른 항목을 고르거나 '지정 안 함'을 선택해 주세요.", + "encounter-not-found": "이 진료가 더 이상 없어요. 다른 진료를 고르거나 선택을 비워 두세요.", "custom-vaccine-not-found": "이 백신이 더 이상 없어요. 목록에서 다시 골라 주세요.", "not-found": "이 예방접종 기록은 더 이상 없어요.", "identity-required": "접종에는 이름이 필요해요. 목록에서 백신을 고르거나 기록에 적힌 이름을 입력하세요.", @@ -222,6 +226,14 @@ "payload_too_large": "운동 데이터가 너무 커서 한 번에 저장할 수 없어요.", "source_not_permitted": "이 연결로는 해당 출처의 운동을 저장할 수 없어요." } + }, + "ai": { + "provider": { + "none": "설정된 AI 제공자가 없어 아무것도 보내지 않았어요. AI 설정에서 하나를 설정하세요." + }, + "budget": { + "exceeded": "오늘의 AI 예산을 모두 사용해서 아무것도 보내지 않았어요. 내일 다시 채워져요." + } } }, "doctorReport": { @@ -1024,7 +1036,8 @@ "typeTrainingReadiness": "트레이닝 준비도", "typeAcuteTrainingLoad": "급성 훈련 부하", "sourceGarmin": "Garmin", - "valueOutOfRange": "{min}에서 {max} {unit} 사이의 값을 입력해 주세요." + "valueOutOfRange": "{min}에서 {max} {unit} 사이의 값을 입력해 주세요.", + "valueOutOfRangeNoUnit": "{min}에서 {max} 사이의 값을 입력해 주세요." }, "metricCatalog": { "title": "HealthLog가 기록할 수 있는 것", @@ -5959,6 +5972,7 @@ "inProgress": "복용 이력 불러오기가 이미 진행 중이에요. 끝날 때까지 기다려 주세요.", "workerUnavailable": "백그라운드 작업자를 쓸 수 없어서 아무것도 불러오지 못했어요.", "failed": "불러오지 못했어요. 아무것도 저장되지 않았어요.", + "stillRunning": "불러오기가 평소보다 오래 걸리고 있어요. 아직 끝날 수 있으니 몇 분 뒤에 복용 기록을 다시 확인해 주세요.", "columnsHeading": "각 열의 역할", "jsonOnly": "JSON 전용", "previewLine": "읽은 행 {rows}개, 불러올 행 {queued}개, 거부된 행 {refused}개", @@ -12494,7 +12508,8 @@ "purgeConfirmBody": "대기 기간이 끝나기 전에 지금 삭제되며 복구할 수 없습니다." }, "picker": { - "capped": "최신 {count}개를 보여 줘요. 이전 문서는 검색으로 찾아 주세요." + "capped": "최신 {count}개를 보여 줘요. 이전 문서는 검색으로 찾아 주세요.", + "cappedSearching": "최근 검색 결과 {count}개를 보여 줘요. 이전 문서는 검색어를 좁혀서 찾아 주세요." } }, "selfDescription": { diff --git a/messages/nl.json b/messages/nl.json index a4aebd961..38b89512e 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -142,11 +142,15 @@ "auth": { "reproof": { "required": "Bevestig je huidige wachtwoord om dit te wijzigen.", - "failed": "De verificatie is mislukt. Er is niets gewijzigd." + "failed": "De verificatie is mislukt. Er is niets gewijzigd.", + "too_weak": "Bevestig met je tweede factor of een passkey. Voor dit account is alleen je wachtwoord niet genoeg." }, "missing": "Je sessie is verlopen, dus er is niets gewijzigd. Meld je opnieuw aan om verder te gaan.", "passkey": { "last_method": "Deze passkey is je enige manier om in te loggen. Stel eerst een wachtwoord in of voeg nog een passkey toe." + }, + "mfa": { + "already_active": "Voor dit account is al een authenticator-app ingesteld. Schakel die eerst uit om een nieuwe in te stellen." } }, "biomarkers": { @@ -204,7 +208,7 @@ "encounter-not-found": "Dit bezoek bestaat niet meer. Kies een ander of geen.", "custom-vaccine-not-found": "Dit vaccin bestaat niet meer. Kies het opnieuw uit de lijst.", "not-found": "Deze vaccinatie is niet meer beschikbaar.", - "identity-required": "Een dosis heeft een naam nodig: kies een vaccin uit de lijst of typ wat er in het boekje staat.", + "identity-required": "Een dosis heeft een naam nodig: kies een vaccin uit de lijst of typ wat er op je vaccinatiebewijs staat.", "booster-invalid": "Controleer de gegevens van de booster en probeer het opnieuw.", "booster-no-antigen": "Deze dosis noemt geen vaccin uit de catalogus, dus er kan geen booster worden gepland.", "booster-out-of-scope": "Je toegang tot dit dossier dekt het plannen van een booster niet." @@ -222,6 +226,14 @@ "payload_too_large": "De trainingsgegevens zijn te groot om in één keer op te slaan.", "source_not_permitted": "Deze koppeling mag geen trainingen onder die bron opslaan." } + }, + "ai": { + "provider": { + "none": "Er is geen AI-provider ingesteld, dus er is niets verzonden. Stel er een in bij de AI-instellingen." + }, + "budget": { + "exceeded": "Het AI-budget voor vandaag is op, dus er is niets verzonden. Morgen is het er weer." + } } }, "doctorReport": { @@ -1024,7 +1036,8 @@ "typeTrainingReadiness": "Trainingsgereedheid", "typeAcuteTrainingLoad": "Acute trainingsbelasting", "sourceGarmin": "Garmin", - "valueOutOfRange": "Vul een waarde in tussen {min} en {max} {unit}." + "valueOutOfRange": "Vul een waarde in tussen {min} en {max} {unit}.", + "valueOutOfRangeNoUnit": "Vul een waarde in tussen {min} en {max}." }, "metricCatalog": { "title": "Wat HealthLog kan bijhouden", @@ -5959,6 +5972,7 @@ "inProgress": "Er loopt al een import van doseringsgeschiedenis. Wacht tot die klaar is.", "workerUnavailable": "De achtergrondverwerking is niet beschikbaar, dus er is niets geïmporteerd.", "failed": "De import is mislukt. Er is niets opgeslagen.", + "stillRunning": "De import duurt langer dan normaal en kan nog afronden. Kijk over een paar minuten opnieuw naar je innames.", "columnsHeading": "Wat elke kolom doet", "jsonOnly": "Alleen JSON", "previewLine": "{rows} rijen gelezen, {queued} zouden worden geïmporteerd, {refused} geweigerd", @@ -12494,7 +12508,8 @@ "purgeConfirmBody": "Het wordt nu verwijderd in plaats van aan het einde van de wachttijd en kan niet worden hersteld." }, "picker": { - "capped": "De nieuwste {count} staan hier. Zoek om een ouder document te vinden." + "capped": "De nieuwste {count} staan hier. Zoek om een ouder document te vinden.", + "cappedSearching": "De nieuwste {count} resultaten staan hier. Verfijn de zoekopdracht om een ouder document te vinden." } }, "selfDescription": { diff --git a/messages/pl.json b/messages/pl.json index 15934f55d..b54a69d49 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -142,11 +142,15 @@ "auth": { "reproof": { "required": "Potwierdź obecne hasło, aby to zmienić.", - "failed": "Weryfikacja nie powiodła się. Nic nie zostało zmienione." + "failed": "Weryfikacja nie powiodła się. Nic nie zostało zmienione.", + "too_weak": "Potwierdź drugim składnikiem lub kluczem dostępu. Na tym koncie samo hasło nie wystarczy." }, "missing": "Twoja sesja wygasła, więc nic nie zostało zmienione. Zaloguj się ponownie, aby kontynuować.", "passkey": { "last_method": "Ten klucz dostępu to Twój jedyny sposób logowania. Najpierw ustaw hasło albo dodaj kolejny klucz dostępu." + }, + "mfa": { + "already_active": "To konto ma już skonfigurowaną aplikację uwierzytelniającą. Najpierw ją wyłącz, aby skonfigurować nową." } }, "biomarkers": { @@ -194,17 +198,17 @@ }, "encounter": { "invalid": "Sprawdź dane wizyty i spróbuj ponownie.", - "practitioner-not-found": "Ten gabinet lub lekarz już nie istnieje. Wybierz inny albo żaden.", - "reminder-not-found": "To badanie już nie istnieje. Wybierz inne albo żadne.", + "practitioner-not-found": "Ten gabinet lub lekarz już nie istnieje. Wybierz inny wpis albo „Nie podano”.", + "reminder-not-found": "To badanie już nie istnieje. Wybierz inne albo pozostaw pole puste.", "reminder-conflict": "Zaplanowana wizyta nie może zamknąć badania, bo jeszcze się nie odbyła." }, "vaccination": { "invalid": "Sprawdź dane szczepienia i spróbuj ponownie.", - "practitioner-not-found": "Ten gabinet lub lekarz już nie istnieje. Wybierz inny albo żaden.", - "encounter-not-found": "Ta wizyta już nie istnieje. Wybierz inną albo żadną.", + "practitioner-not-found": "Ten gabinet lub lekarz już nie istnieje. Wybierz inny wpis albo „Nie podano”.", + "encounter-not-found": "Ta wizyta już nie istnieje. Wybierz inną albo pozostaw pole puste.", "custom-vaccine-not-found": "Ta szczepionka już nie istnieje. Wybierz ją ponownie z listy.", "not-found": "To szczepienie nie jest już dostępne.", - "identity-required": "Dawka potrzebuje nazwy: wybierz szczepionkę z listy albo wpisz to, co widnieje w dokumentacji.", + "identity-required": "Dawka potrzebuje nazwy: wybierz szczepionkę z listy albo wpisz to, co podano w karcie szczepień.", "booster-invalid": "Sprawdź dane dawki przypominającej i spróbuj ponownie.", "booster-no-antigen": "Ta dawka nie wskazuje szczepionki z katalogu, więc nie da się zaplanować dawki przypominającej.", "booster-out-of-scope": "Twój dostęp do tej kartoteki nie obejmuje planowania dawki przypominającej." @@ -222,6 +226,14 @@ "payload_too_large": "Dane treningu są za duże, by zapisać je za jednym razem.", "source_not_permitted": "To połączenie nie może zapisywać treningów pod tym źródłem." } + }, + "ai": { + "provider": { + "none": "Nie skonfigurowano dostawcy AI, więc nic nie zostało wysłane. Skonfiguruj go w ustawieniach AI." + }, + "budget": { + "exceeded": "Dzisiejszy budżet AI został wykorzystany, więc nic nie zostało wysłane. Odnowi się jutro." + } } }, "doctorReport": { @@ -1024,7 +1036,8 @@ "typeTrainingReadiness": "Gotowość do treningu", "typeAcuteTrainingLoad": "Ostre obciążenie treningowe", "sourceGarmin": "Garmin", - "valueOutOfRange": "Wpisz wartość od {min} do {max} {unit}." + "valueOutOfRange": "Wpisz wartość od {min} do {max} {unit}.", + "valueOutOfRangeNoUnit": "Wpisz wartość od {min} do {max}." }, "metricCatalog": { "title": "Co HealthLog potrafi śledzić", @@ -5959,6 +5972,7 @@ "inProgress": "Import historii dawek już trwa. Poczekaj, aż się zakończy.", "workerUnavailable": "Proces w tle jest niedostępny, więc nic nie zostało zaimportowane.", "failed": "Import nie udał się. Nic nie zostało zapisane.", + "stillRunning": "Import trwa dłużej niż zwykle i może się jeszcze zakończyć. Sprawdź swoje dawki ponownie za kilka minut.", "columnsHeading": "Co się dzieje z każdą kolumną", "jsonOnly": "tylko JSON", "previewLine": "Odczytane wiersze: {rows}, do zaimportowania: {queued}, odrzucone: {refused}", @@ -12494,7 +12508,8 @@ "purgeConfirmBody": "Zostanie usunięty teraz zamiast po okresie oczekiwania i nie będzie można go przywrócić." }, "picker": { - "capped": "Widać {count} najnowszych. Starszy dokument znajdziesz przez wyszukiwanie." + "capped": "Widać {count} najnowszych. Starszy dokument znajdziesz przez wyszukiwanie.", + "cappedSearching": "Widać {count} najnowszych wyników. Zawęź wyszukiwanie, aby znaleźć starszy." } }, "selfDescription": { diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 6d9069711..d2b0e72e0 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -142,11 +142,15 @@ "auth": { "reproof": { "required": "Confirme sua senha atual para alterar isto.", - "failed": "A verificação não foi concluída. Nada foi alterado." + "failed": "A verificação não foi concluída. Nada foi alterado.", + "too_weak": "Confirme com seu segundo fator ou uma chave de acesso. Nesta conta, só a senha não basta." }, "missing": "Sua sessão terminou, então nada foi alterado. Entre novamente para continuar.", "passkey": { "last_method": "Esta chave de acesso é sua única forma de entrar. Defina uma senha ou adicione outra chave de acesso primeiro." + }, + "mfa": { + "already_active": "Esta conta já tem um aplicativo autenticador configurado. Desative-o primeiro para configurar um novo." } }, "biomarkers": { @@ -195,8 +199,8 @@ "encounter": { "invalid": "Verifique os dados da consulta e tente novamente.", "practitioner-not-found": "Esta clínica ou médico não existe mais. Escolha outro ou nenhum.", - "reminder-not-found": "Este check não existe mais. Escolha outro ou nenhum.", - "reminder-conflict": "Uma consulta planejada não pode fechar um check, porque ainda não aconteceu." + "reminder-not-found": "Este check-up não existe mais. Escolha outro ou nenhum.", + "reminder-conflict": "Uma consulta planejada não pode fechar um check-up, porque ainda não aconteceu." }, "vaccination": { "invalid": "Verifique os dados da vacina e tente novamente.", @@ -222,6 +226,14 @@ "payload_too_large": "Os dados do treino são grandes demais para salvar de uma vez.", "source_not_permitted": "Esta conexão não pode salvar treinos com essa origem." } + }, + "ai": { + "provider": { + "none": "Nenhum provedor de IA está configurado, então nada foi enviado. Configure um nas configurações de IA." + }, + "budget": { + "exceeded": "O orçamento de IA de hoje acabou, então nada foi enviado. Ele é renovado amanhã." + } } }, "doctorReport": { @@ -1024,7 +1036,8 @@ "typeTrainingReadiness": "Prontidão para treinar", "typeAcuteTrainingLoad": "Carga de treino aguda", "sourceGarmin": "Garmin", - "valueOutOfRange": "Informe um valor entre {min} e {max} {unit}." + "valueOutOfRange": "Informe um valor entre {min} e {max} {unit}.", + "valueOutOfRangeNoUnit": "Informe um valor entre {min} e {max}." }, "metricCatalog": { "title": "O que o HealthLog pode acompanhar", @@ -5959,6 +5972,7 @@ "inProgress": "Uma importação de histórico de doses já está em andamento. Espere ela terminar.", "workerUnavailable": "O worker em segundo plano não está disponível, então nada foi importado.", "failed": "A importação falhou. Nada foi gravado.", + "stillRunning": "A importação está demorando mais que o normal e ainda pode terminar. Confira suas doses de novo em alguns minutos.", "columnsHeading": "O que cada coluna faz", "jsonOnly": "Somente JSON", "previewLine": "{rows} linhas lidas, {queued} seriam importadas, {refused} recusadas", @@ -12494,7 +12508,8 @@ "purgeConfirmBody": "Ele é removido agora, e não no fim do período de espera, e não pode ser restaurado." }, "picker": { - "capped": "Mostrando os {count} mais recentes. Use a busca para encontrar um documento mais antigo." + "capped": "Mostrando os {count} mais recentes. Use a busca para encontrar um documento mais antigo.", + "cappedSearching": "Mostrando os {count} resultados mais recentes. Refine a busca para encontrar um mais antigo." } }, "selfDescription": { @@ -13425,7 +13440,7 @@ "created": "Consulta salva", "updated": "Consulta atualizada", "saveFailed": "Não foi possível salvar a consulta.", - "checkupNotClosed": "Consulta salva. O check permanece aberto; seu acesso não cobre isso.", + "checkupNotClosed": "Consulta salva. O check-up permanece aberto; seu acesso não cobre isso.", "status": { "planned": "Planejada", "done": "Comparecida", diff --git a/messages/ru.json b/messages/ru.json index f260a981f..85db67ff1 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -142,11 +142,15 @@ "auth": { "reproof": { "required": "Подтвердите текущий пароль, чтобы изменить это.", - "failed": "Не подтверждено. Ничего не изменено." + "failed": "Не подтверждено. Ничего не изменено.", + "too_weak": "Подтвердите вторым фактором или ключом доступа. Для этого аккаунта одного пароля недостаточно." }, "missing": "Сеанс завершён, поэтому ничего не изменено. Войдите снова, чтобы продолжить.", "passkey": { "last_method": "Этот ключ доступа остаётся вашим единственным способом входа. Сначала задайте пароль или добавьте ещё один ключ доступа." + }, + "mfa": { + "already_active": "Для этого аккаунта уже настроено приложение-аутентификатор. Сначала выключите его, чтобы настроить новое." } }, "biomarkers": { @@ -194,14 +198,14 @@ }, "encounter": { "invalid": "Проверьте данные визита и попробуйте снова.", - "practitioner-not-found": "Этой клиники или врача больше нет. Выберите другую запись или никакую.", - "reminder-not-found": "Этого осмотра больше нет. Выберите другой или никакой.", + "practitioner-not-found": "Этой клиники или врача больше нет. Выберите другую запись или «Не указано».", + "reminder-not-found": "Этого осмотра больше нет. Выберите другой или оставьте поле пустым.", "reminder-conflict": "Запланированный визит не может закрыть осмотр, потому что он ещё не состоялся." }, "vaccination": { "invalid": "Проверьте данные вакцинации и попробуйте снова.", - "practitioner-not-found": "Этой клиники или врача больше нет. Выберите другую запись или никакую.", - "encounter-not-found": "Этого визита больше нет. Выберите другой или никакой.", + "practitioner-not-found": "Этой клиники или врача больше нет. Выберите другую запись или «Не указано».", + "encounter-not-found": "Этого визита больше нет. Выберите другой или оставьте поле пустым.", "custom-vaccine-not-found": "Этой вакцины больше нет. Выберите её в списке ещё раз.", "not-found": "Этой вакцинации больше нет.", "identity-required": "У дозы должно быть название: выберите вакцину из списка или введите то, что указано в записи.", @@ -218,10 +222,18 @@ "workout": { "batch": { "invalid": "Проверьте данные тренировки и попробуйте снова.", - "too_large": "Слишком много тренировок за раз. Отправляйте меньше за один раз.", + "too_large": "Слишком много тренировок за раз. Отправляйте их небольшими частями.", "payload_too_large": "Данные тренировки слишком велики, чтобы сохранить их за один раз.", "source_not_permitted": "Это подключение не может сохранять тренировки с таким источником." } + }, + "ai": { + "provider": { + "none": "Поставщик ИИ не настроен, поэтому ничего не отправлено. Настройте его в настройках ИИ." + }, + "budget": { + "exceeded": "Бюджет ИИ на сегодня исчерпан, поэтому ничего не отправлено. Он обновится завтра." + } } }, "doctorReport": { @@ -1024,7 +1036,8 @@ "typeTrainingReadiness": "Готовность к тренировке", "typeAcuteTrainingLoad": "Острая тренировочная нагрузка", "sourceGarmin": "Garmin", - "valueOutOfRange": "Введите значение от {min} до {max} {unit}." + "valueOutOfRange": "Введите значение от {min} до {max} {unit}.", + "valueOutOfRangeNoUnit": "Введите значение от {min} до {max}." }, "metricCatalog": { "title": "Что умеет отслеживать HealthLog", @@ -5959,6 +5972,7 @@ "inProgress": "Импорт истории доз уже выполняется. Дождитесь его завершения.", "workerUnavailable": "Фоновый обработчик недоступен, поэтому ничего не импортировано.", "failed": "Импорт не удался. Ничего не записано.", + "stillRunning": "Импорт идёт дольше обычного и ещё может завершиться. Проверьте свои приёмы через несколько минут.", "columnsHeading": "Что делает каждый столбец", "jsonOnly": "Только JSON", "previewLine": "Прочитано строк: {rows}, будет импортировано: {queued}, отклонено: {refused}", @@ -12494,7 +12508,8 @@ "purgeConfirmBody": "Он будет удалён сейчас, а не в конце периода ожидания, и его нельзя будет восстановить." }, "picker": { - "capped": "Показаны {count} самых новых. Более старый документ найдите через поиск." + "capped": "Показаны {count} самых новых. Более старый документ найдите через поиск.", + "cappedSearching": "Показаны {count} самых новых совпадений. Уточните поиск, чтобы найти более старый документ." } }, "selfDescription": { diff --git a/package.json b/package.json index 2713cdc57..bcc7d9f4b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.47.2", + "version": "1.47.3", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/playwright.config.ts b/playwright.config.ts index c046e2d21..be75f3589 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -279,13 +279,6 @@ export default defineConfig({ "setup-flow-a11y.spec.ts", "setup-flow-connect.spec.ts", "setup-flow-locale-overflow.spec.ts", - // The shared-provider offer journey states INSTANCE-wide facts to - // reason about: it writes the operator key into `app_settings` and - // replaces every `provider_health` row for both shared tags, then - // puts both back. Serial mode only orders tests inside one file, so - // a second project would hold and clear that state underneath the - // first — and underneath any other spec that reads AI availability. - "onboarding-ai-offer.spec.ts", // The delegated-writes journey invites, accepts and revokes between // its own two accounts. A second project would send a second invitation // between the same pair while the first is live, and meet the 409. diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 80f3d5962..d5943dc37 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -7240,8 +7240,8 @@ model CoachFact { // `status` is an APP-SIDE closed enum (NOT a DB enum, to keep the migration // purely additive): proposed | active | met | abandoned | review_due | reviewed. // The last two are the v1.22 (W9, C2) n-of-1 experiment lifecycle: an -// "experiment" is just a CoachPlan with a reviewDate; when the review fires and -// the read-back is generated, `outcomeEncrypted` carries the grounded result. +// "experiment" is just a CoachPlan with a reviewDate; the review only moves the +// status. `outcomeEncrypted` is no longer written or read (see the column). model CoachPlan { id String @id @default(cuid()) userId String @map("user_id") @@ -7260,9 +7260,10 @@ model CoachPlan { status String @default("proposed") // Optional review checkpoint the Coach can recall ("how did this go by …"). reviewDate DateTime? @map("review_date") - // v1.22 (W9, C2) — the n-of-1 read-back prose, grounded in the user's own - // before/after numbers and encrypted at rest like the cue/action. Written - // once when an experiment's review is processed; surfaced behind a flag. + // v1.22 (W9, C2) — the n-of-1 read-back prose an experiment's review used + // to write, encrypted at rest like the cue/action. Nothing writes or reads + // it now; older rows stay, rotated with the key and carried by the backup. + /// @internal: kept for rows written before the review stopped writing it. outcomeEncrypted Bytes? @map("outcome_encrypted") // Provenance — nullable so a future manual-entry surface can leave it null. sourceConversationId String? @map("source_conversation_id") diff --git a/public/sw.js b/public/sw.js index 19178159e..c6696d309 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.47.2"; + /* @sw-version-fallback */ "v1.47.3"; 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/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index c5cf7adec..c8d00c413 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 84, - "total": 93809, + "total": 93815, "entries": { "app/medications/page-client.tsx": 839, "components/charts/mood-chart.tsx": 1166, @@ -82,7 +82,7 @@ "lib/search/search-records.ts": 996, "lib/sharing/grants.ts": 806, "lib/support/report.ts": 903, - "lib/telegram-webhook-handlers.ts": 1313, + "lib/telegram-webhook-handlers.ts": 1319, "lib/validations/inbound-documents.ts": 1018, "lib/withings/sync-sleep.ts": 884, "lib/withings/sync.ts": 933, diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index 097316b9d..4d777bd80 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 336, - "total": 109383, + "total": 109357, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275, @@ -36,7 +36,7 @@ "app/api/nutrients/batch/route.ts :: postBatch": 304, "app/api/onboarding/complete/route.ts :: completeNeedsFlow": 218, "app/api/record-settings/[family]/route.ts :: apiHandler(arg) #2": 262, - "app/api/user/ai-provider/route.ts :: apiHandler(arg) #2": 250, + "app/api/user/ai-provider/route.ts :: apiHandler(arg) #2": 224, "app/api/withings/callback/route.ts :: apiHandler(arg)": 247, "app/auth/login/page.tsx :: LoginPage": 567, "app/auth/register/page.tsx :: RegisterPage": 285, @@ -171,7 +171,7 @@ "components/mood/mood-form.tsx :: MoodForm": 514, "components/mood/mood-list.tsx :: MoodList": 1109, "components/mood/mood-tag-picker.tsx :: MoodTagPicker": 257, - "components/onboarding/confirm-screen.tsx :: ConfirmScreen": 255, + "components/onboarding/confirm-screen.tsx :: ConfirmScreen": 251, "components/onboarding/getting-started-checklist.tsx :: GettingStartedChecklist": 423, "components/onboarding/tour.tsx :: OnboardingTour": 544, "components/practitioners/practitioner-list.tsx :: PractitionerList": 225, @@ -330,8 +330,8 @@ "lib/record-activity/activity-verb.ts :: recordActivityVerbLine": 261, "lib/strava/sync.ts :: runStravaSync": 219, "lib/support/report.ts :: collectSupportReportFacts": 332, - "lib/targets/build-response.ts :: buildTargetsResponse": 330, - "lib/targets/vitals-builder.ts :: buildVitalTargets": 324, + "lib/targets/build-response.ts :: buildTargetsResponse": 332, + "lib/targets/vitals-builder.ts :: buildVitalTargets": 326, "lib/telegram-webhook-handlers.ts :: handleCallback": 582, "lib/telegram-webhook-handlers.ts :: handleTextMessage": 322, "lib/withings/sync-activity.ts :: syncUserActivity": 377, diff --git a/src/__tests__/baselines/test-as-never.json b/src/__tests__/baselines/test-as-never.json index f6803f372..6bacf6fb6 100644 --- a/src/__tests__/baselines/test-as-never.json +++ b/src/__tests__/baselines/test-as-never.json @@ -1,7 +1,7 @@ { "$comment": "as never casts in tests, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.", "count": 523, - "total": 5458, + "total": 5456, "entries": { "__tests__/api/auth/login/mfa-branch.test.ts": 10, "__tests__/api/auth/mfa/verify.test.ts": 9, @@ -33,8 +33,8 @@ "app/api/admin/settings/module-availability/__tests__/route.test.ts": 13, "app/api/admin/settings/web-push-vapid/generate/__tests__/route.test.ts": 4, "app/api/admin/status/__tests__/route.test.ts": 3, - "app/api/admin/users/__tests__/route.test.ts": 3, "app/api/admin/users/[id]/__tests__/route.test.ts": 8, + "app/api/admin/users/__tests__/route.test.ts": 3, "app/api/ai/test/__tests__/route.test.ts": 26, "app/api/analytics/__tests__/route.test.ts": 25, "app/api/auth/claim/__tests__/route.test.ts": 13, @@ -55,24 +55,24 @@ "app/api/auth/passkey/login-verify/__tests__/native-token.test.ts": 7, "app/api/auth/passkey/login-verify/__tests__/oidc-only.test.ts": 4, "app/api/auth/passkey/login-verify/__tests__/refusals.test.ts": 6, - "app/api/auth/passkeys/__tests__/route.test.ts": 3, "app/api/auth/passkeys/[id]/__tests__/route.test.ts": 17, + "app/api/auth/passkeys/__tests__/route.test.ts": 3, "app/api/auth/password/__tests__/route.test.ts": 5, "app/api/auth/register/__tests__/route.test.ts": 25, - "app/api/biomarkers/__tests__/error-contract.test.ts": 3, "app/api/biomarkers/[id]/__tests__/delete-record-auth.test.ts": 3, "app/api/biomarkers/[id]/__tests__/route.test.ts": 23, + "app/api/biomarkers/__tests__/error-contract.test.ts": 3, "app/api/coach/plans/__tests__/route.test.ts": 23, "app/api/coach/reminders/__tests__/route.test.ts": 15, "app/api/coach/suggested-actions/__tests__/route.test.ts": 7, "app/api/consent/ai/__tests__/route.test.ts": 10, "app/api/consent/ai/latest/__tests__/route.test.ts": 42, "app/api/consent/ai/web/__tests__/route.test.ts": 6, - "app/api/custom-metrics/__tests__/route.test.ts": 14, "app/api/custom-metrics/[id]/__tests__/route.test.ts": 23, "app/api/custom-metrics/[id]/entries/__tests__/note-encryption.test.ts": 13, "app/api/custom-metrics/[id]/entries/__tests__/route.test.ts": 12, "app/api/custom-metrics/[id]/entries/__tests__/soft-delete-restore.test.ts": 6, + "app/api/custom-metrics/__tests__/route.test.ts": 14, "app/api/cycle/__tests__/external-id-floor.test.ts": 9, "app/api/cycle/calendar/__tests__/day-entries.test.ts": 9, "app/api/cycle/calendar/__tests__/verdict-round-trip.test.ts": 26, @@ -85,9 +85,8 @@ "app/api/dashboard/summary/__tests__/module-gate.test.ts": 6, "app/api/dashboard/summary/__tests__/route.test.ts": 86, "app/api/dashboard/widgets/__tests__/route.test.ts": 49, - "app/api/devices/__tests__/route.test.ts": 32, "app/api/devices/[id]/__tests__/route.test.ts": 10, - "app/api/documents/inbound/__tests__/route.test.ts": 62, + "app/api/devices/__tests__/route.test.ts": 32, "app/api/documents/inbound/[id]/__tests__/route.test.ts": 56, "app/api/documents/inbound/[id]/chat/__tests__/route.test.ts": 42, "app/api/documents/inbound/[id]/extract/__tests__/route.test.ts": 38, @@ -96,6 +95,7 @@ "app/api/documents/inbound/[id]/suggest/__tests__/route.test.ts": 12, "app/api/documents/inbound/[id]/summary/__tests__/route.test.ts": 31, "app/api/documents/inbound/[id]/thumbnail/__tests__/route.test.ts": 5, + "app/api/documents/inbound/__tests__/route.test.ts": 62, "app/api/documents/inbound/layout/__tests__/route.test.ts": 11, "app/api/documents/inbound/reindex/__tests__/route.test.ts": 9, "app/api/export/__tests__/measurement-streaming.test.ts": 9, @@ -118,8 +118,8 @@ "app/api/illness/episodes/[id]/day-logs/__tests__/route.test.ts": 8, "app/api/illness/episodes/[id]/restore/__tests__/route.test.ts": 9, "app/api/import/__tests__/route.test.ts": 47, - "app/api/import/apple-health-export/__tests__/route.test.ts": 8, "app/api/import/apple-health-export/[jobId]/status/__tests__/route.test.ts": 4, + "app/api/import/apple-health-export/__tests__/route.test.ts": 8, "app/api/import/csv/__tests__/route.test.ts": 4, "app/api/import/health-connect-export/__tests__/route.test.ts": 2, "app/api/ingest/medication/__tests__/route.test.ts": 28, @@ -134,14 +134,14 @@ "app/api/insights/correlations/__tests__/route.test.ts": 12, "app/api/insights/derived/__tests__/route.test.ts": 12, "app/api/insights/derived/batch/__tests__/route.test.ts": 21, - "app/api/insights/ecg/__tests__/route.test.ts": 5, "app/api/insights/ecg/[id]/__tests__/route.test.ts": 5, + "app/api/insights/ecg/__tests__/route.test.ts": 5, "app/api/insights/generate/__tests__/route.test.ts": 54, "app/api/insights/layout/__tests__/route.test.ts": 23, "app/api/insights/metric-status/__tests__/route.test.ts": 7, "app/api/insights/mood-status/__tests__/route.test.ts": 10, - "app/api/insights/patterns/__tests__/route.test.ts": 1, "app/api/insights/patterns/[id]/__tests__/route.test.ts": 4, + "app/api/insights/patterns/__tests__/route.test.ts": 1, "app/api/insights/pregenerate/__tests__/route.test.ts": 4, "app/api/insights/provider-chain/__tests__/put.test.ts": 4, "app/api/insights/provider-chain/__tests__/route.test.ts": 13, @@ -163,12 +163,12 @@ "app/api/mcp/oauth/__tests__/token.test.ts": 14, "app/api/mcp/tokens/__tests__/route.test.ts": 3, "app/api/measurement-categories/__tests__/route.test.ts": 3, + "app/api/measurements/[id]/__tests__/route.test.ts": 8, "app/api/measurements/__tests__/arrival-wiring.test.ts": 7, "app/api/measurements/__tests__/group-by-day.test.ts": 10, "app/api/measurements/__tests__/put-duplicate-timestamp.test.ts": 9, "app/api/measurements/__tests__/range-aggregation-route.test.ts": 10, "app/api/measurements/__tests__/zod-multi-issue.test.ts": 2, - "app/api/measurements/[id]/__tests__/route.test.ts": 8, "app/api/measurements/batch/__tests__/aggregated-hr-wire.test.ts": 9, "app/api/measurements/batch/__tests__/duplicate-prefilter.test.ts": 1, "app/api/measurements/batch/__tests__/hr-bucket-observability.test.ts": 3, @@ -185,16 +185,16 @@ "app/api/measurements/series-batch/__tests__/route.test.ts": 10, "app/api/measurements/series/__tests__/route.test.ts": 96, "app/api/measurements/series/__tests__/unknown-query-param-open.test.ts": 6, - "app/api/medications/__tests__/route.test.ts": 43, "app/api/medications/[id]/__tests__/route.test.ts": 52, "app/api/medications/[id]/compliance/__tests__/route.test.ts": 18, "app/api/medications/[id]/dose-history/__tests__/route.test.ts": 15, "app/api/medications/[id]/glp1/__tests__/route.test.ts": 47, + "app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts": 63, "app/api/medications/[id]/intake/__tests__/route.test.ts": 45, "app/api/medications/[id]/intake/__tests__/source-provenance.test.ts": 12, - "app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts": 63, "app/api/medications/[id]/intake/import/__tests__/route.test.ts": 9, "app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts": 24, + "app/api/medications/__tests__/route.test.ts": 43, "app/api/medications/compliance/__tests__/route.test.ts": 10, "app/api/medications/extract/__tests__/route.test.ts": 37, "app/api/medications/intake/__tests__/route.test.ts": 59, @@ -202,14 +202,14 @@ "app/api/medications/intake/bulk/__tests__/route.test.ts": 68, "app/api/medications/intake/dose-history-import/__tests__/route.test.ts": 7, "app/api/medications/layout/__tests__/route.test.ts": 17, - "app/api/mental-health/assessments/__tests__/route.test.ts": 12, "app/api/mental-health/assessments/[id]/__tests__/route.test.ts": 7, + "app/api/mental-health/assessments/__tests__/route.test.ts": 12, "app/api/meta/capabilities/__tests__/route.test.ts": 1, "app/api/monitoring/glitchtip/__tests__/route.test.ts": 2, "app/api/monitoring/umami-script/__tests__/route.test.ts": 2, "app/api/monitoring/umami/[...path]/__tests__/route.test.ts": 4, - "app/api/mood-entries/__tests__/route.test.ts": 6, "app/api/mood-entries/[id]/__tests__/route.test.ts": 8, + "app/api/mood-entries/__tests__/route.test.ts": 6, "app/api/mood-entries/bulk-delete/__tests__/route.test.ts": 10, "app/api/mood-entries/bulk/__tests__/route.test.ts": 17, "app/api/mood-entries/restore/__tests__/route.test.ts": 11, @@ -236,14 +236,14 @@ "app/api/sleep/rhythm/__tests__/route.test.ts": 6, "app/api/sync/changes/__tests__/module-gate.test.ts": 7, "app/api/sync/state/__tests__/route.test.ts": 8, - "app/api/telegram/webhook/__tests__/route.test.ts": 43, + "app/api/telegram/webhook/__tests__/route.test.ts": 41, "app/api/tokens/documents/__tests__/route.test.ts": 25, "app/api/tokens/measurements/__tests__/route.test.ts": 25, "app/api/tokens/workouts/__tests__/route.test.ts": 25, "app/api/user/ai-provider/__tests__/legacy-private-host.test.ts": 2, "app/api/user/ai-provider/__tests__/route.test.ts": 12, - "app/api/user/avatar/__tests__/route.test.ts": 13, "app/api/user/avatar/[id]/__tests__/route.test.ts": 5, + "app/api/user/avatar/__tests__/route.test.ts": 13, "app/api/user/profile/__tests__/route.test.ts": 29, "app/api/user/thresholds/__tests__/route.test.ts": 7, "app/api/whoop/callback/__tests__/route.test.ts": 2, @@ -251,8 +251,8 @@ "app/api/withings/callback/__tests__/route.test.ts": 9, "app/api/withings/connect/__tests__/route.test.ts": 4, "app/api/withings/credentials/__tests__/route.test.ts": 4, - "app/api/withings/webhook/__tests__/route.test.ts": 26, "app/api/withings/webhook/[token]/__tests__/route.test.ts": 8, + "app/api/withings/webhook/__tests__/route.test.ts": 26, "app/api/workouts/__tests__/batch-create.test.ts": 13, "app/api/workouts/__tests__/canonical-dedup.test.ts": 20, "app/api/workouts/batch/__tests__/unstable-external-id.test.ts": 4, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index 8db3dc8c1..1313dbe84 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", "count": 2872, - "total": 9153, + "total": 9152, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -172,9 +172,9 @@ "app/api/auth/refresh/route.ts": 2, "app/api/auth/register/__tests__/route.test.ts": 5, "app/api/auth/register/route.ts": 10, - "app/api/biomarkers/__tests__/error-contract.test.ts": 1, "app/api/biomarkers/[id]/__tests__/route.test.ts": 2, "app/api/biomarkers/[id]/route.ts": 6, + "app/api/biomarkers/__tests__/error-contract.test.ts": 1, "app/api/biomarkers/route.ts": 4, "app/api/c/[token]/unlock/__tests__/route.test.ts": 1, "app/api/c/[token]/unlock/route.ts": 1, @@ -184,12 +184,12 @@ "app/api/coach/about-me/questions/__tests__/route-module-gate.test.ts": 1, "app/api/coach/about-me/questions/route.ts": 3, "app/api/coach/about-me/route.ts": 4, - "app/api/coach/plans/__tests__/route.test.ts": 1, "app/api/coach/plans/[id]/route.ts": 2, + "app/api/coach/plans/__tests__/route.test.ts": 1, "app/api/coach/plans/route.ts": 1, "app/api/coach/reminder-suggestions/route.ts": 1, - "app/api/coach/reminders/__tests__/route.test.ts": 1, "app/api/coach/reminders/[id]/route.ts": 1, + "app/api/coach/reminders/__tests__/route.test.ts": 1, "app/api/coach/reminders/route.ts": 1, "app/api/coach/suggested-actions/__tests__/route.test.ts": 1, "app/api/coach/suggested-actions/route.ts": 1, @@ -197,9 +197,9 @@ "app/api/consent/ai/latest/__tests__/route.test.ts": 3, "app/api/consent/ai/latest/route.ts": 3, "app/api/consent/ai/route.ts": 3, + "app/api/custom-metrics/[id]/entries/[entryId]/route.ts": 2, "app/api/custom-metrics/[id]/entries/__tests__/note-encryption.test.ts": 1, "app/api/custom-metrics/[id]/entries/__tests__/soft-delete-restore.test.ts": 1, - "app/api/custom-metrics/[id]/entries/[entryId]/route.ts": 2, "app/api/custom-metrics/[id]/entries/route.ts": 4, "app/api/custom-metrics/[id]/route.ts": 1, "app/api/custom-metrics/route.ts": 1, @@ -209,8 +209,8 @@ "app/api/cycle/day-logs/[id]/route.ts": 4, "app/api/cycle/day-logs/route.ts": 1, "app/api/cycle/period/route.ts": 4, - "app/api/cycle/symptoms/custom/__tests__/custom-crud.test.ts": 1, "app/api/cycle/symptoms/custom/[key]/route.ts": 1, + "app/api/cycle/symptoms/custom/__tests__/custom-crud.test.ts": 1, "app/api/cycle/symptoms/custom/route.ts": 3, "app/api/dashboard/chart-overlay-prefs/__tests__/route.test.ts": 1, "app/api/dashboard/chart-overlay-prefs/route.ts": 2, @@ -220,11 +220,10 @@ "app/api/dashboard/summary/route.ts": 5, "app/api/dashboard/widgets/__tests__/route.test.ts": 4, "app/api/dashboard/widgets/route.ts": 14, - "app/api/devices/__tests__/route.test.ts": 1, "app/api/devices/[id]/__tests__/route.test.ts": 1, "app/api/devices/[id]/route.ts": 1, + "app/api/devices/__tests__/route.test.ts": 1, "app/api/devices/route.ts": 4, - "app/api/documents/inbound/__tests__/route.test.ts": 1, "app/api/documents/inbound/[id]/__tests__/route.test.ts": 1, "app/api/documents/inbound/[id]/chat/__tests__/route.test.ts": 2, "app/api/documents/inbound/[id]/chat/route.ts": 1, @@ -239,6 +238,7 @@ "app/api/documents/inbound/[id]/suggest/route.ts": 2, "app/api/documents/inbound/[id]/summary/route.ts": 2, "app/api/documents/inbound/[id]/thumbnail/route.ts": 1, + "app/api/documents/inbound/__tests__/route.test.ts": 1, "app/api/documents/inbound/reindex/route.ts": 1, "app/api/documents/inbound/route.ts": 2, "app/api/documents/inbound/usage/route.ts": 1, @@ -257,10 +257,10 @@ "app/api/export/mood/route.ts": 2, "app/api/family-history/[id]/route.ts": 2, "app/api/family-history/route.ts": 1, + "app/api/fhir/Patient/$everything/route.ts": 1, "app/api/fhir/__tests__/observation-series.test.ts": 1, "app/api/fhir/__tests__/route.test.ts": 1, "app/api/fhir/metadata/route.ts": 1, - "app/api/fhir/Patient/$everything/route.ts": 1, "app/api/fitbit/callback/route.ts": 1, "app/api/fitbit/connect/route.ts": 1, "app/api/fitbit/credentials/route.ts": 1, @@ -289,6 +289,11 @@ "app/api/insights/breathing-screening/route.ts": 2, "app/api/insights/cards/__tests__/route.test.ts": 2, "app/api/insights/cards/route.ts": 8, + "app/api/insights/chat/[id]/attachments/[documentId]/route.ts": 1, + "app/api/insights/chat/[id]/attachments/__tests__/route.test.ts": 1, + "app/api/insights/chat/[id]/attachments/route.ts": 1, + "app/api/insights/chat/[id]/messages/[messageId]/trail/__tests__/route.test.ts": 1, + "app/api/insights/chat/[id]/route.ts": 1, "app/api/insights/chat/__tests__/dialog-harness.ts": 1, "app/api/insights/chat/__tests__/golden-transcripts.test.ts": 1, "app/api/insights/chat/__tests__/route-activity.test.ts": 1, @@ -300,19 +305,14 @@ "app/api/insights/chat/__tests__/route-snapshot-once.test.ts": 2, "app/api/insights/chat/__tests__/route-tool-mode.test.ts": 4, "app/api/insights/chat/__tests__/route-workout-scope.test.ts": 1, - "app/api/insights/chat/[id]/attachments/__tests__/route.test.ts": 1, - "app/api/insights/chat/[id]/attachments/[documentId]/route.ts": 1, - "app/api/insights/chat/[id]/attachments/route.ts": 1, - "app/api/insights/chat/[id]/messages/[messageId]/trail/__tests__/route.test.ts": 1, - "app/api/insights/chat/[id]/route.ts": 1, "app/api/insights/chat/fenced/__tests__/route.test.ts": 1, "app/api/insights/chat/fenced/route.ts": 1, "app/api/insights/chat/messages/[id]/feedback/route.ts": 3, "app/api/insights/chat/route.ts": 9, "app/api/insights/coach-read/route.ts": 2, + "app/api/insights/coach/facts/[id]/route.ts": 2, "app/api/insights/coach/facts/__tests__/route-memory.test.ts": 1, "app/api/insights/coach/facts/__tests__/route.test.ts": 1, - "app/api/insights/coach/facts/[id]/route.ts": 2, "app/api/insights/coach/facts/route.ts": 1, "app/api/insights/coach/nudge-status/route.ts": 1, "app/api/insights/coach/seen/route.ts": 1, @@ -323,9 +323,9 @@ "app/api/insights/derived/batch/__tests__/route.test.ts": 3, "app/api/insights/derived/batch/route.ts": 6, "app/api/insights/derived/route.ts": 5, - "app/api/insights/ecg/__tests__/route.test.ts": 1, "app/api/insights/ecg/[id]/__tests__/route.test.ts": 1, "app/api/insights/ecg/[id]/route.ts": 2, + "app/api/insights/ecg/__tests__/route.test.ts": 1, "app/api/insights/ecg/route.ts": 1, "app/api/insights/feedback/route.ts": 2, "app/api/insights/generate/__tests__/route.test.ts": 11, @@ -352,7 +352,7 @@ "app/api/insights/rhythm-events/route.ts": 2, "app/api/insights/settings/route.ts": 1, "app/api/insights/targets/__tests__/route.test.ts": 1, - "app/api/insights/targets/route.ts": 2, + "app/api/insights/targets/route.ts": 1, "app/api/insights/weight-status/route.ts": 2, "app/api/integrations/healthkit/__tests__/route.test.ts": 4, "app/api/integrations/healthkit/route.ts": 3, @@ -364,8 +364,8 @@ "app/api/internal/deploy-webhook/route.ts": 1, "app/api/internal/web-vitals/__tests__/route.test.ts": 1, "app/api/internal/web-vitals/route.ts": 1, - "app/api/labs/__tests__/error-contract.test.ts": 1, "app/api/labs/[id]/route.ts": 7, + "app/api/labs/__tests__/error-contract.test.ts": 1, "app/api/labs/ocr/capability/route.ts": 1, "app/api/labs/ocr/commit/__tests__/route.test.ts": 2, "app/api/labs/ocr/commit/route.ts": 4, @@ -385,13 +385,13 @@ "app/api/measurement-reminders/[id]/skip/route.ts": 1, "app/api/measurement-reminders/[id]/snooze/route.ts": 1, "app/api/measurement-reminders/route.ts": 3, + "app/api/measurements/[id]/__tests__/route.test.ts": 2, + "app/api/measurements/[id]/route.ts": 16, "app/api/measurements/__tests__/arrival-wiring.test.ts": 1, "app/api/measurements/__tests__/group-by-day.test.ts": 8, "app/api/measurements/__tests__/put-duplicate-timestamp.test.ts": 1, "app/api/measurements/__tests__/range-aggregation-route.test.ts": 3, "app/api/measurements/__tests__/zod-multi-issue.test.ts": 4, - "app/api/measurements/[id]/__tests__/route.test.ts": 2, - "app/api/measurements/[id]/route.ts": 16, "app/api/measurements/batch/__tests__/aggregated-hr-wire.test.ts": 1, "app/api/measurements/batch/__tests__/duplicate-prefilter.test.ts": 2, "app/api/measurements/batch/__tests__/hr-bucket-observability.test.ts": 1, @@ -408,7 +408,6 @@ "app/api/measurements/series-batch/route.ts": 1, "app/api/measurements/series/__tests__/route.test.ts": 5, "app/api/measurements/series/route.ts": 6, - "app/api/medications/__tests__/route.test.ts": 10, "app/api/medications/[id]/__tests__/route.test.ts": 8, "app/api/medications/[id]/api-endpoint/route.ts": 2, "app/api/medications/[id]/cadence/route.ts": 10, @@ -420,10 +419,10 @@ "app/api/medications/[id]/dose-history/route.ts": 14, "app/api/medications/[id]/glp1/__tests__/route.test.ts": 3, "app/api/medications/[id]/glp1/route.ts": 8, - "app/api/medications/[id]/intake/__tests__/route.test.ts": 11, - "app/api/medications/[id]/intake/__tests__/source-provenance.test.ts": 1, "app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts": 9, "app/api/medications/[id]/intake/[eventId]/route.ts": 4, + "app/api/medications/[id]/intake/__tests__/route.test.ts": 11, + "app/api/medications/[id]/intake/__tests__/source-provenance.test.ts": 1, "app/api/medications/[id]/intake/bulk-delete/route.ts": 7, "app/api/medications/[id]/intake/import/__tests__/route.test.ts": 2, "app/api/medications/[id]/intake/import/route.ts": 6, @@ -431,11 +430,12 @@ "app/api/medications/[id]/inventory/[itemId]/route.ts": 8, "app/api/medications/[id]/inventory/route.ts": 4, "app/api/medications/[id]/phase-config/route.ts": 4, - "app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts": 1, "app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts": 9, + "app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts": 1, "app/api/medications/[id]/schedule-revisions/route.ts": 3, "app/api/medications/[id]/side-effects/[logId]/route.ts": 2, "app/api/medications/[id]/side-effects/route.ts": 5, + "app/api/medications/__tests__/route.test.ts": 10, "app/api/medications/categories/[key]/route.ts": 1, "app/api/medications/categories/route.ts": 1, "app/api/medications/compliance/__tests__/route.test.ts": 2, @@ -452,9 +452,9 @@ "app/api/medications/route.ts": 2, "app/api/mental-health/assessments/__tests__/route.test.ts": 2, "app/api/mental-health/assessments/route.ts": 6, - "app/api/mood-entries/__tests__/route.test.ts": 7, "app/api/mood-entries/[id]/__tests__/route.test.ts": 4, "app/api/mood-entries/[id]/route.ts": 22, + "app/api/mood-entries/__tests__/route.test.ts": 7, "app/api/mood-entries/bulk-delete/__tests__/route.test.ts": 1, "app/api/mood-entries/bulk-delete/route.ts": 4, "app/api/mood-entries/bulk/__tests__/route.test.ts": 5, @@ -544,12 +544,12 @@ "app/api/withings/connect/__tests__/route.test.ts": 1, "app/api/withings/connect/route.ts": 3, "app/api/withings/status/route.ts": 1, - "app/api/withings/webhook/__tests__/route.test.ts": 1, "app/api/withings/webhook/[token]/__tests__/route.test.ts": 1, + "app/api/withings/webhook/__tests__/route.test.ts": 1, "app/api/withings/webhook/route.ts": 2, + "app/api/workouts/[id]/route.ts": 2, "app/api/workouts/__tests__/batch-create.test.ts": 3, "app/api/workouts/__tests__/canonical-dedup.test.ts": 3, - "app/api/workouts/[id]/route.ts": 2, "app/api/workouts/batch/route.ts": 3, "app/api/workouts/route.ts": 2, "app/auth/claim/page.tsx": 1, @@ -651,19 +651,19 @@ "app/loading.tsx": 2, "app/mcp/__tests__/route.test.ts": 5, "app/measurements/page.tsx": 5, + "app/medications/[id]/history/page.tsx": 1, + "app/medications/[id]/page.tsx": 2, "app/medications/__tests__/category-filter.test.tsx": 2, "app/medications/__tests__/list-view-toggle.test.tsx": 5, "app/medications/__tests__/page-prefetch.test.tsx": 1, "app/medications/__tests__/take-all-due-affordance.test.tsx": 1, - "app/medications/[id]/history/page.tsx": 1, - "app/medications/[id]/page.tsx": 2, "app/medications/new/page.tsx": 1, "app/medications/page-client.tsx": 50, "app/mental-wellbeing/page.tsx": 1, "app/mood/page-client.tsx": 2, "app/not-found.tsx": 2, - "app/onboarding/__tests__/page.test.tsx": 1, "app/onboarding/[step]/page.tsx": 1, + "app/onboarding/__tests__/page.test.tsx": 1, "app/onboarding/handover/page.tsx": 1, "app/onboarding/page.tsx": 2, "app/privacy/__tests__/page.test.tsx": 1, diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts index 3bdc790fb..762adce82 100644 --- a/src/__tests__/destructive-control-guard.test.ts +++ b/src/__tests__/destructive-control-guard.test.ts @@ -469,17 +469,15 @@ const REGISTRY: DestructiveEntry[] = [ destroys: "a visit with its reason, outcome and the links it collected — and, for a booked one, the appointment reminder that would have nudged about it", triggers: ["components/encounters/encounter-sheet.tsx"], - // The route soft-deletes and a restore route exists, but nothing in the - // product reaches it yet, so from a person's side the visit is gone. This - // becomes "restorable" the day a surface offers the undo, not before. - recovery: "tombstoned-no-restore", + // The delete toast offers the Undo that calls the restore route. + recovery: "restorable", confirm: ["AlertDialog"], }, { file: "hooks/use-practitioners.ts", destroys: "one address-book entry", triggers: ["components/practitioners/practitioner-list.tsx"], - recovery: "tombstoned-no-restore", + recovery: "restorable", confirm: ["AlertDialog"], }, { @@ -487,9 +485,8 @@ const REGISTRY: DestructiveEntry[] = [ destroys: "one logged dose with its lot, site and the document links it collected — the booster reminder it once satisfied is deliberately not rewound", triggers: ["components/vaccinations/vaccination-sheet.tsx"], - // The route soft-deletes and a restore route exists, but no surface offers - // the undo yet, so from a person's side the dose is gone. - recovery: "tombstoned-no-restore", + // The delete toast offers the Undo that calls the restore route. + recovery: "restorable", confirm: ["AlertDialog"], }, diff --git a/src/__tests__/display-transform-rounding-guard.test.ts b/src/__tests__/display-transform-rounding-guard.test.ts index 8d0a4a5fd..b24003542 100644 --- a/src/__tests__/display-transform-rounding-guard.test.ts +++ b/src/__tests__/display-transform-rounding-guard.test.ts @@ -45,7 +45,7 @@ const REGISTRY = "lib/measurements/display-transform.ts"; /** * The one module allowed to import the unrounded escape hatch. The target * adapter needs the unrounded pound edge of a canonical guardrail so it can - * round it INWARD — rounding to the nearest tenth first can put the pound + * round it INWARD: rounding to the nearest tenth first can put the pound * edge outside the kilogram window, and the server rejects what converts * back from it. */ diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index 0609e0dcc..c46b1d1b1 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -1557,7 +1557,7 @@ const DELEGABLE_ROUTES: Record = { }, "app/api/insights/targets/route.ts": { domain: "record", - why: "The record's resolved targets. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + why: "The record's resolved targets: the bands and reference ranges the dashboard snapshot already paints on the whole-record grant, read the same way, with no provider on the path. GET only; nothing is written here.", }, "app/api/insights/ecg/route.ts": { domain: "record", @@ -2108,11 +2108,6 @@ const DELEGABLE_MANAGE_ROUTES: Record = { conditions: [], why: "A score's daily course, read from the same rows the score pages and the day view read. Deterministic, no provider, nothing enqueued.", }, - "app/api/insights/targets/route.ts": { - domain: "record", - conditions: [], - why: "The record's resolved targets.", - }, "app/api/insights/ecg/route.ts": { domain: "record", conditions: [], @@ -2315,9 +2310,9 @@ const ACTOR_ROUTES: Record = { * 276 -> 278. * * The Insights presence read joins the record list and the manage literal: - * the tab strip's gate over the overview, counts and flags only. 278 -> 280. + * the tab strip's gate over the overview, counts and flags only. 277 -> 279. */ -const FROZEN_ENTRY_COUNT = 280; +const FROZEN_ENTRY_COUNT = 279; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — diff --git a/src/app/api/auth/me/mfa/totp/setup/route.ts b/src/app/api/auth/me/mfa/totp/setup/route.ts index 9c94d919e..06c78fd52 100644 --- a/src/app/api/auth/me/mfa/totp/setup/route.ts +++ b/src/app/api/auth/me/mfa/totp/setup/route.ts @@ -37,6 +37,8 @@ import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { generateTotpSecret, buildOtpauthUri } from "@/lib/auth/mfa/totp"; import { checkCookieEnrollmentProof } from "@/lib/auth/existing-factor-proof"; +const TOTP_ALREADY_ACTIVE_CODE = "auth.mfa.already_active"; + export const dynamic = "force-dynamic"; const SETUP_RATE_LIMIT = 5; @@ -64,7 +66,11 @@ export const POST = apiHandler(async (req: Request) => { // live secret out from under the user's authenticator. if (user.totpConfirmedAt) { annotate({ action: { name: "auth.mfa.totp.setup.already_active" } }); - throw new HttpError(409, "A second factor is already active"); + throw new HttpError( + 409, + "A second factor is already active", + TOTP_ALREADY_ACTIVE_CODE, + ); } if (auth.transport === "cookie") { diff --git a/src/app/api/auth/passkey/register-options/route.ts b/src/app/api/auth/passkey/register-options/route.ts index 968d9948a..e725a37bf 100644 --- a/src/app/api/auth/passkey/register-options/route.ts +++ b/src/app/api/auth/passkey/register-options/route.ts @@ -6,6 +6,7 @@ import { import { createMfaAuthenticationOptions } from "@/lib/auth/mfa/webauthn"; import { RECENT_PROOF_MAX_AGE_MS, + REPROOF_FAILED_CODE, REPROOF_REQUIRED_CODE, isSecondFactorProof, recordReproofFailure, @@ -29,8 +30,12 @@ import { apiHandler, requireCookieAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { stepUpMintSchema } from "@/lib/validations/step-up"; +// A wrong or missing proof, not an ended session: the code keeps the web from +// reading the bare 401 as "sign in again". const proofRequired = () => - apiError("Fresh existing-factor proof required", 401); + apiError("Fresh existing-factor proof required", 401, { + errorCode: REPROOF_FAILED_CODE, + }); export const POST = apiHandler(async (request: NextRequest) => { const { user, session } = await requireCookieAuth(); diff --git a/src/app/api/auth/passkey/register-verify/route.ts b/src/app/api/auth/passkey/register-verify/route.ts index e50fc106c..62ba82f4f 100644 --- a/src/app/api/auth/passkey/register-verify/route.ts +++ b/src/app/api/auth/passkey/register-verify/route.ts @@ -13,6 +13,7 @@ import { import { NextRequest } from "next/server"; import { apiHandler, requireCookieAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; +import { REPROOF_FAILED_CODE } from "@/lib/auth/existing-factor-proof"; export const POST = apiHandler(async (request: NextRequest) => { const { user, session } = await requireCookieAuth(); @@ -40,7 +41,9 @@ export const POST = apiHandler(async (request: NextRequest) => { (freshSession.mfaVerifiedAt && freshSession.mfaVerifiedAt.getTime() < Date.now() - 5 * 60 * 1000) ) { - return apiError("Fresh reauthentication proof required", 401); + return apiError("Fresh reauthentication proof required", 401, { + errorCode: REPROOF_FAILED_CODE, + }); } const claim = await consumeRegistrationChallenge({ @@ -49,12 +52,11 @@ export const POST = apiHandler(async (request: NextRequest) => { sessionId: session.id, }); if (!claim.ok) { - return apiError( - claim.reason === "wrong_context" - ? "Fresh reauthentication proof required" - : "Registration challenge invalid or expired", - claim.reason === "wrong_context" ? 401 : 400, - ); + return claim.reason === "wrong_context" + ? apiError("Fresh reauthentication proof required", 401, { + errorCode: REPROOF_FAILED_CODE, + }) + : apiError("Registration challenge invalid or expired", 400); } let verification; diff --git a/src/app/api/auth/step-up/route.ts b/src/app/api/auth/step-up/route.ts index 6592d81c2..e0ee2c1ea 100644 --- a/src/app/api/auth/step-up/route.ts +++ b/src/app/api/auth/step-up/route.ts @@ -40,6 +40,7 @@ import { rateLimitHeaders, } from "@/lib/rate-limit"; import { + REPROOF_FAILED_CODE, REPROOF_LIMIT, REPROOF_WINDOW_MS, reproofBucket, @@ -132,7 +133,9 @@ export const POST = apiHandler(async (request: NextRequest) => { action: { name: "auth.stepup.mint.failed" }, meta: { method: parsed.data.method, reason: failure }, }); - return apiError("Verification failed", 401); + return apiError("Verification failed", 401, { + errorCode: REPROOF_FAILED_CODE, + }); } const { token, expiresAt } = await mintStepUpElevation({ diff --git a/src/app/api/custom-metrics/[id]/__tests__/route.test.ts b/src/app/api/custom-metrics/[id]/__tests__/route.test.ts index 76d08eb28..f9f693eb4 100644 --- a/src/app/api/custom-metrics/[id]/__tests__/route.test.ts +++ b/src/app/api/custom-metrics/[id]/__tests__/route.test.ts @@ -225,6 +225,7 @@ describe("PATCH /api/custom-metrics/[id]", () => { const res = await PATCH(patchReq({ name: "Other" }), params("cm-1")); expect(res.status).toBe(409); + expect((await res.json()).meta.errorCode).toBe("customMetric.name_taken"); expect(prisma.customMetric.update).not.toHaveBeenCalled(); }); }); diff --git a/src/app/api/custom-metrics/[id]/route.ts b/src/app/api/custom-metrics/[id]/route.ts index 2b149bd4d..a26fdbe35 100644 --- a/src/app/api/custom-metrics/[id]/route.ts +++ b/src/app/api/custom-metrics/[id]/route.ts @@ -111,7 +111,9 @@ export const PATCH = apiHandler( select: { id: true }, }); if (clash) { - return apiError("A custom metric with this name already exists", 409); + return apiError("A custom metric with this name already exists", 409, { + errorCode: "customMetric.name_taken", + }); } } diff --git a/src/app/api/custom-metrics/__tests__/route.test.ts b/src/app/api/custom-metrics/__tests__/route.test.ts index 1a9a0492c..fba6c5514 100644 --- a/src/app/api/custom-metrics/__tests__/route.test.ts +++ b/src/app/api/custom-metrics/__tests__/route.test.ts @@ -134,6 +134,9 @@ describe("POST /api/custom-metrics", () => { const res = await POST(postReq({ name: "Grip strength", unit: "kg" })); expect(res.status).toBe(409); + // The form names the clash by this code: a 409 without it can be the + // first save still running under the same key. + expect((await res.json()).meta.errorCode).toBe("customMetric.name_taken"); expect(prisma.customMetric.create).not.toHaveBeenCalled(); }); diff --git a/src/app/api/custom-metrics/route.ts b/src/app/api/custom-metrics/route.ts index dfc9aa12e..88fcc1bca 100644 --- a/src/app/api/custom-metrics/route.ts +++ b/src/app/api/custom-metrics/route.ts @@ -100,7 +100,9 @@ async function postCustomMetric(request: NextRequest) { select: { id: true, deletedAt: true }, }); if (existing && existing.deletedAt === null) { - return apiError("A custom metric with this name already exists", 409); + return apiError("A custom metric with this name already exists", 409, { + errorCode: "customMetric.name_taken", + }); } // Field-by-field assignment — never spread `parsed.data`. diff --git a/src/app/api/insights/provider-chain/__tests__/put.test.ts b/src/app/api/insights/provider-chain/__tests__/put.test.ts index 3c7a666e5..f58ce7b78 100644 --- a/src/app/api/insights/provider-chain/__tests__/put.test.ts +++ b/src/app/api/insights/provider-chain/__tests__/put.test.ts @@ -238,6 +238,48 @@ describe("PUT /api/insights/provider-chain", () => { ]); }); + it("drops the effort of a gateway the person switches off", async () => { + // A disabled gateway entry with an effort is the gateway form's holder, + // which the picked gateway still serves behind; a switch-off is not. + storedChain([ + { + providerType: "openai-compatible", + priority: 1, + enabled: true, + reasoningEffort: "low", + }, + ]); + await put([{ providerType: "openai-compatible", enabled: false }]); + expect(persisted()).toEqual([ + { providerType: "openai-compatible", priority: 1, enabled: false }, + ]); + }); + + it("keeps the holder's effort when the chain is saved around it", async () => { + storedChain([ + { providerType: "local", priority: 1, enabled: true }, + { + providerType: "openai-compatible", + priority: 2, + enabled: false, + reasoningEffort: "low", + }, + ]); + await put([ + { providerType: "openai-compatible", enabled: false }, + { providerType: "local", enabled: true }, + ]); + expect(persisted()).toEqual([ + { + providerType: "openai-compatible", + priority: 1, + enabled: false, + reasoningEffort: "low", + }, + { providerType: "local", priority: 2, enabled: true }, + ]); + }); + it("clears it on null", async () => { storedChain([ { diff --git a/src/app/api/insights/provider-chain/route.ts b/src/app/api/insights/provider-chain/route.ts index 4685916f1..3a2e15e00 100644 --- a/src/app/api/insights/provider-chain/route.ts +++ b/src/app/api/insights/provider-chain/route.ts @@ -118,18 +118,28 @@ export const PUT = apiHandler(async (request: NextRequest) => { where: { id: user.id }, select: { aiProviderChain: true }, }); + const storedChain = parseProviderChain(stored?.aiProviderChain ?? null); const storedEffort = new Map( - parseProviderChain(stored?.aiProviderChain ?? null).map((e) => [ - e.providerType, - e.reasoningEffort ?? null, - ]), + storedChain.map((e) => [e.providerType, e.reasoningEffort ?? null]), ); + // A disabled gateway entry with an effort is the holder the gateway form + // writes, which the picked gateway still serves behind + // (`servesPickedGateway`). Switching the gateway off here is the person's + // word, so its effort goes with the switch. + const gatewayWasOn = + storedChain.find((e) => e.providerType === "openai-compatible")?.enabled !== + false; const normalised = parsed.data.chain.map((entry, idx) => { - const reasoningEffort = isReasoningProviderType(entry.providerType) - ? entry.reasoningEffort === undefined - ? (storedEffort.get(entry.providerType) ?? null) - : entry.reasoningEffort - : null; + const switchedOff = + entry.providerType === "openai-compatible" && + !entry.enabled && + gatewayWasOn; + const reasoningEffort = + !isReasoningProviderType(entry.providerType) || switchedOff + ? null + : entry.reasoningEffort === undefined + ? (storedEffort.get(entry.providerType) ?? null) + : entry.reasoningEffort; return { providerType: entry.providerType, priority: idx + 1, diff --git a/src/app/api/insights/targets/route.ts b/src/app/api/insights/targets/route.ts index 315f0a691..6d5077e60 100644 --- a/src/app/api/insights/targets/route.ts +++ b/src/app/api/insights/targets/route.ts @@ -15,9 +15,10 @@ import { buildTargetsResponse } from "@/lib/targets/build-response"; export const dynamic = "force-dynamic"; export const GET = apiHandler(async () => { - // v1.37.0 — MANAGE-level read: computed over the whole record, with no - // provider anywhere on the path. - const { user } = await requireRecordAuth("manage", "record"); + // A read of the whole record, with no provider anywhere on the path, at the + // level the dashboard snapshot answers: the weight band and the BMI chart + // read it inside a shared record, and a read or write grant got a 403. + const { user } = await requireRecordAuth("read", "record"); // v1.16.8 — stale-while-revalidate read. The cold build is the multi-query // walk in `buildTargetsResponse`; past the 60 s fresh TTL the prior body // serves immediately while ONE background rebuild warms the cell. Writes diff --git a/src/app/api/measurements/bulk-delete/__tests__/route.test.ts b/src/app/api/measurements/bulk-delete/__tests__/route.test.ts index 55f4cb17c..839656912 100644 --- a/src/app/api/measurements/bulk-delete/__tests__/route.test.ts +++ b/src/app/api/measurements/bulk-delete/__tests__/route.test.ts @@ -156,6 +156,18 @@ describe("POST /api/measurements/bulk-delete", () => { }); }); + it("leaves a computed score out of the selection it deletes", async () => { + // The read is scoped like the write, so a computed row never reaches it. + vi.mocked(prisma.measurement.findMany).mockResolvedValue([]); + vi.mocked(prisma.measurement.updateMany).mockResolvedValue({ count: 0 }); + + await POST(postReq({ ids: ["score-1"] })); + + expect( + vi.mocked(prisma.measurement.findMany).mock.calls[0][0]?.where, + ).toMatchObject({ source: { not: "COMPUTED" } }); + }); + it("collapses the rollup recompute to the unique (type, day) set, not per-row", async () => { // 4 deleted rows: 2× WEIGHT on the same UTC day, 1× WEIGHT next day, // 1× PULSE same day as the first → 3 distinct (type, day) keys. diff --git a/src/app/api/measurements/bulk-delete/route.ts b/src/app/api/measurements/bulk-delete/route.ts index 16e2a22ee..c146b2580 100644 --- a/src/app/api/measurements/bulk-delete/route.ts +++ b/src/app/api/measurements/bulk-delete/route.ts @@ -81,9 +81,17 @@ async function postBulkDelete(request: NextRequest): Promise { // other half and the cuff's pulse, `bloodPressureSiblings`) into the same // statement, so the shared `deletedAt` lets the restore route bring the // reading back whole. + // + // A computed score (recovery, strain, stress) is skipped: the server + // rewrites it from its inputs, and a tombstone kept the recompute out. const { affected, count } = await prisma.$transaction(async (tx) => { const selected = await tx.measurement.findMany({ - where: { id: { in: ids }, userId: user.id, deletedAt: null }, + where: { + id: { in: ids }, + userId: user.id, + deletedAt: null, + source: { not: "COMPUTED" }, + }, select: { id: true, type: true, source: true, measuredAt: true }, }); const siblings = await bloodPressureSiblings(tx, user.id, selected, "live"); diff --git a/src/app/api/onboarding/complete/__tests__/route.test.ts b/src/app/api/onboarding/complete/__tests__/route.test.ts index c33b8a690..164f97dd2 100644 --- a/src/app/api/onboarding/complete/__tests__/route.test.ts +++ b/src/app/api/onboarding/complete/__tests__/route.test.ts @@ -90,6 +90,20 @@ describe("POST /api/onboarding/complete", () => { ); }); + it("stores a date of birth from the iOS app as its day in the owner's zone", async () => { + vi.mocked(getSession).mockResolvedValue({ + ...SESSION_OK, + user: { ...SESSION_OK.user, timezone: "Europe/Berlin" }, + } as unknown as Awaited>); + // 14 May picked at 00:30 in Berlin. + const res = await POST(req({ dateOfBirth: "1960-05-13T23:30:00Z" })); + expect(res.status).toBe(200); + const data = vi.mocked(prisma.user.update).mock.calls[0]![0]!.data as { + dateOfBirth: Date; + }; + expect(data.dateOfBirth.toISOString()).toBe("1960-05-14T12:00:00.000Z"); + }); + it("still refuses an unsupported gender", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); const res = await POST(req({ gender: "diverse" })); diff --git a/src/app/api/onboarding/complete/route.ts b/src/app/api/onboarding/complete/route.ts index f51f93d22..42e5c788b 100644 --- a/src/app/api/onboarding/complete/route.ts +++ b/src/app/api/onboarding/complete/route.ts @@ -29,6 +29,8 @@ import { readManagedProfileForGuardian } from "@/lib/managed-profiles/lifecycle" import { writeRecordModulePreferences } from "@/lib/record-settings/modules"; import { NextRequest } from "next/server"; import { onboardingCompleteSchema } from "@/lib/validations/onboarding"; +import { dateOfBirthForStorage } from "@/lib/tz/date-only"; +import { resolveStoredTimezone } from "@/lib/tz/resolver"; /** * Complete the onboarding flow. Saves optional profile data and marks @@ -84,10 +86,12 @@ export const POST = apiHandler(async (request: NextRequest) => { } if (result.data.dateOfBirth) { - const dob = new Date(result.data.dateOfBirth); - if (!isNaN(dob.getTime())) { - data.dateOfBirth = dob; - } + // The calendar day in the owner's zone: the iOS app sends an instant. + const dob = dateOfBirthForStorage( + result.data.dateOfBirth, + await resolveStoredTimezone(user.timezone), + ); + if (dob) data.dateOfBirth = dob; } if (result.data.gender) { diff --git a/src/app/api/telegram/webhook/__tests__/route.test.ts b/src/app/api/telegram/webhook/__tests__/route.test.ts index 4781f80c2..9a92baadf 100644 --- a/src/app/api/telegram/webhook/__tests__/route.test.ts +++ b/src/app/api/telegram/webhook/__tests__/route.test.ts @@ -132,7 +132,9 @@ const MEDICATION = { id: "med-1", name: "Ramipril" }; * schedule projection (distinguishable by `select.schedules`); every other * `medication.findFirst` in the webhook wants the bare id/name shape. The * default projection carries no schedules, so the generic dispatch tests - * exercise the ad-hoc (standalone create) path unchanged. + * exercise the ad-hoc (standalone create) path unchanged. The dose lookup + * (`findTelegramDoseMedication`) reads every match by id or name through + * `findMany`; a list read (no id, no name) finds nothing unless a test says. */ function wireMedicationFindFirst( schedules: unknown[] = [], @@ -155,6 +157,14 @@ function wireMedicationFindFirst( } return Promise.resolve(medication); }) as never); + vi.mocked(prisma.medication.findMany).mockImplementation(((args: { + where?: Record; + }) => + Promise.resolve( + args?.where && ("id" in args.where || "name" in args.where) + ? [medication] + : [], + )) as never); } beforeEach(() => { @@ -168,7 +178,6 @@ beforeEach(() => { vi.mocked(prisma.user.findFirst).mockResolvedValue(TG_USER as never); vi.mocked(isModuleEnabled).mockResolvedValue(true); wireMedicationFindFirst(); - vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never); vi.mocked(prisma.medication.update).mockResolvedValue({} as never); vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue(null); vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValue({} as never); @@ -305,6 +314,7 @@ describe("Telegram webhook — callback dispatch", () => { expect(res.status).toBe(200); expect(prisma.medication.findFirst).not.toHaveBeenCalled(); + expect(prisma.medication.findMany).not.toHaveBeenCalled(); expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); expect(prisma.medication.update).not.toHaveBeenCalled(); expect(deleteMessage).not.toHaveBeenCalled(); @@ -469,7 +479,7 @@ describe("Telegram webhook — callback dispatch", () => { }); it("rejects 'taken' for an inactive medication and acks with the localised error", async () => { - vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce(null); + vi.mocked(prisma.medication.findMany).mockResolvedValueOnce([]); const res = await POST(tgRequest(callbackUpdate("taken:med-1"))); expect(res.status).toBe(200); @@ -487,7 +497,7 @@ describe("Telegram webhook — callback dispatch", () => { }); it("'snooze' for a missing medication acks with medicationNotFound (no update)", async () => { - vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce(null); + vi.mocked(prisma.medication.findMany).mockResolvedValueOnce([]); const res = await POST(tgRequest(callbackUpdate("snooze:med-X:60"))); expect(res.status).toBe(200); expect(prisma.medication.update).not.toHaveBeenCalled(); @@ -642,7 +652,7 @@ describe("Telegram webhook — text-message dispatch", () => { }); it("/add with no active medications replies with the 'no active' message", async () => { - vi.mocked(prisma.medication.findMany).mockResolvedValueOnce([] as never); + vi.mocked(prisma.medication.findMany).mockResolvedValueOnce([]); const res = await POST( tgRequest({ update_id: 203, @@ -675,8 +685,8 @@ describe("Telegram webhook — text-message dispatch", () => { }), ); expect(res.status).toBe(200); - // markMedicationTaken → findFirst on active medication - expect(prisma.medication.findFirst).toHaveBeenCalled(); + // The list, then markMedicationTaken's lookup of the one it found. + expect(prisma.medication.findMany).toHaveBeenCalledTimes(2); expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1); }); @@ -717,15 +727,6 @@ describe("Telegram webhook — text-message dispatch", () => { }); it("'taken Ramipril' (text command) creates an intake event for the matched medication", async () => { - vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce({ - id: "med-1", - } as never); - // Second findFirst inside markMedicationTaken - vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce({ - id: "med-1", - name: "Ramipril", - } as never); - const res = await POST( tgRequest({ update_id: 206, @@ -767,8 +768,8 @@ describe("Telegram webhook — text-message dispatch", () => { }); it("'taken UnknownMed' replies with the medication-not-found message and writes nothing", async () => { - // First findFirst (lookup by name) returns null - vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce(null); + // The lookup by name finds nothing. + vi.mocked(prisma.medication.findMany).mockResolvedValueOnce([]); const res = await POST( tgRequest({ @@ -912,6 +913,29 @@ describe("Telegram webhook — slot convergence (v1.16.9)", () => { }); }); + it("books the reminder's own dose on the last day of a course after it ended", async () => { + // The course ran through 9 June; yesterday's 09:00 reminder is confirmed + // the next morning. The dose belongs to a day the course covered. + wireMedicationFindFirst([scheduleRow], [], { + ...MEDICATION, + dose: "5 mg", + startsOn: null, + endsOn: new Date("2026-06-09T00:00:00.000Z"), + endedAt: null, + }); + vi.mocked(prisma.telegramReminderMessage.findFirst).mockResolvedValue({ + date: "2026-06-09", + timeOfDay: "09:00", + } as never); + + await POST(tgRequest(callbackUpdate("taken:med-1"))); + + expect(prisma.$transaction).toHaveBeenCalled(); + expect(vi.mocked(answerTelegramCallbackQuery).mock.calls[0][2]).not.toMatch( + /not found or inactive/i, + ); + }); + it("a take converges via band attribution when no reminder row matches", async () => { vi.mocked(prisma.telegramReminderMessage.findFirst).mockResolvedValue(null); const res = await POST(tgRequest(callbackUpdate("taken:med-1"))); @@ -1224,7 +1248,7 @@ describe("POST /api/telegram/webhook — record-only medications", () => { it("the 'taken' keyword resolves tracked medications only", async () => { await POST(tgRequest(textUpdate("taken Ramipril"))); - const where = whereOf(vi.mocked(prisma.medication.findFirst).mock.calls[0]); + const where = whereOf(vi.mocked(prisma.medication.findMany).mock.calls[0]); expect(where).toMatchObject({ active: true, trackIntake: true }); }); @@ -1232,7 +1256,7 @@ describe("POST /api/telegram/webhook — record-only medications", () => { await POST(tgRequest(callbackUpdate("taken:med-1"))); // The gate lookup: a record-only medication resolves to nothing here, // so no dose is written (later lookups only bind an admitted dose). - const calls = vi.mocked(prisma.medication.findFirst).mock.calls; + const calls = vi.mocked(prisma.medication.findMany).mock.calls; expect(calls.length).toBeGreaterThan(0); expect(whereOf(calls[0])).toMatchObject({ id: "med-1", @@ -1248,7 +1272,7 @@ describe("POST /api/telegram/webhook — record-only medications", () => { * silent, so a reminder button left in the chat, the /add list and the * "taken" keyword do nothing either. */ -describe("POST /api/telegram/webhook — Medications module off", () => { +describe("POST /api/telegram/webhook with the Medications module off", () => { function textUpdate(text: string) { return { update_id: 400, @@ -1299,7 +1323,7 @@ describe("POST /api/telegram/webhook — Medications module off", () => { * since ended, which would also take a unit off the stock. The list side is * pinned in `src/lib/__tests__/telegram-dose-medications.test.ts`. */ -describe("POST /api/telegram/webhook — ended course", () => { +describe("POST /api/telegram/webhook on an ended course", () => { const DAY_MS = 24 * 60 * 60 * 1000; const ENDED = { ...MEDICATION, diff --git a/src/app/api/user/ai-provider/__tests__/route.test.ts b/src/app/api/user/ai-provider/__tests__/route.test.ts index 481a4791f..1e0ac20f8 100644 --- a/src/app/api/user/ai-provider/__tests__/route.test.ts +++ b/src/app/api/user/ai-provider/__tests__/route.test.ts @@ -751,6 +751,24 @@ describe("PATCH /api/user/ai-provider — reasoning setting (#1126)", () => { ]); }); + it("switches the gateway's entry on when the gateway form saves it as the pick", async () => { + // The disabled holder above, written for a picked gateway, read as the + // person switching off the provider they had just picked. + storedChain(null); + await patch( + patchRequest({ + provider: "OPENAI_COMPATIBLE", + compatReasoningEffort: "low", + }), + ); + const chain = written()?.aiProviderChain as Array<{ + providerType: string; + }>; + expect( + chain.find((e) => e.providerType === "openai-compatible"), + ).toMatchObject({ enabled: true, reasoningEffort: "low" }); + }); + it("does not touch the chain when Default is restated on a default chain", async () => { storedChain(null); await patch( diff --git a/src/app/api/user/ai-provider/route.ts b/src/app/api/user/ai-provider/route.ts index 09a99b994..97e5a951b 100644 --- a/src/app/api/user/ai-provider/route.ts +++ b/src/app/api/user/ai-provider/route.ts @@ -16,6 +16,7 @@ import { providerWorkAuthorityForRecord, } from "@/lib/sharing/provider-work-authority"; import { annotate } from "@/lib/logging/context"; +import type { z } from "zod/v4"; import { aiProviderPatchSchema } from "@/lib/validations/ai-provider"; import { chainWithReasoningEffort, @@ -191,6 +192,51 @@ export const GET = apiHandler(async () => { }); }); +/** + * #1126 — the chain to write after the Local or gateway form set its entry's + * reasoning effort, or undefined when nothing changed. Stored on the entry in + * `aiProviderChain` rather than in a column of their own, so the setting is + * per entry and a chain can mix a thinking model with one that does not know + * the field. See `chainWithReasoningEffort` for how a default chain, or a + * provider absent from the chain, holds the value. + */ +async function chainAfterReasoningSave( + userId: string, + body: Pick< + z.infer, + "localReasoningEffort" | "compatReasoningEffort" + >, + pickedNow: unknown, +): Promise { + const current = await prisma.user.findUnique({ + where: { id: userId }, + select: { aiProviderChain: true, aiProvider: true }, + }); + let chain: unknown = current?.aiProviderChain ?? null; + if (body.localReasoningEffort !== undefined) { + chain = chainWithReasoningEffort(chain, "local", body.localReasoningEffort); + } + if (body.compatReasoningEffort !== undefined) { + // Saving the gateway form picks the gateway, so its entry is on: a + // disabled one read as the person switching off what they had just picked. + const picked = pickedNow !== undefined ? pickedNow : current?.aiProvider; + chain = chainWithReasoningEffort( + chain, + "openai-compatible", + body.compatReasoningEffort, + String(picked ?? "").toUpperCase() === "OPENAI_COMPATIBLE", + ); + } + // Written only when it changed: restating Default on a chain that was + // never customised must not freeze today's default chain into the row. + const before = serializeProviderChain( + parseProviderChain(current?.aiProviderChain ?? null), + ); + return serializeProviderChain(parseProviderChain(chain)) !== before + ? chain + : undefined; +} + export const PATCH = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); annotate({ action: { name: "user.ai-provider.update" } }); @@ -337,42 +383,16 @@ export const PATCH = apiHandler(async (request: NextRequest) => { updates.aiResponseTimeoutSeconds = body.responseTimeoutSeconds; } - // ── #1126 — reasoning settings of the Local and gateway entries ── - // Stored on the entry in `aiProviderChain` rather than in a column of their - // own, so the setting is per entry and a chain can mix a thinking model with - // one that does not know the field. See `chainWithReasoningEffort` for how a - // default chain, or a provider absent from the chain, holds the value. const reasoningTouched = body.localReasoningEffort !== undefined || body.compatReasoningEffort !== undefined; if (reasoningTouched) { - const current = await prisma.user.findUnique({ - where: { id: user.id }, - select: { aiProviderChain: true }, - }); - let chain: unknown = current?.aiProviderChain ?? null; - if (body.localReasoningEffort !== undefined) { - chain = chainWithReasoningEffort( - chain, - "local", - body.localReasoningEffort, - ); - } - if (body.compatReasoningEffort !== undefined) { - chain = chainWithReasoningEffort( - chain, - "openai-compatible", - body.compatReasoningEffort, - ); - } - // Written only when it changed: restating Default on a chain that was - // never customised must not freeze today's default chain into the row. - const before = serializeProviderChain( - parseProviderChain(current?.aiProviderChain ?? null), + const chain = await chainAfterReasoningSave( + user.id, + body, + updates.aiProvider, ); - if (serializeProviderChain(parseProviderChain(chain)) !== before) { - updates.aiProviderChain = chain; - } + if (chain !== undefined) updates.aiProviderChain = chain; } if (Object.keys(updates).length === 0 && !reasoningTouched) { diff --git a/src/app/insights/bmi/__tests__/page.test.tsx b/src/app/insights/bmi/__tests__/page.test.tsx index b0f342bc4..9c65f7420 100644 --- a/src/app/insights/bmi/__tests__/page.test.tsx +++ b/src/app/insights/bmi/__tests__/page.test.tsx @@ -23,6 +23,7 @@ const state = { viewerHeight: null as number | null, inSharedRecord: true, recordHeight: 118 as number | null, + bmiRange: { min: 18.5, max: 24.9 } as { min: number; max: number } | null, }; vi.mock("@/hooks/use-auth", () => ({ useAuth: () => ({ @@ -41,7 +42,10 @@ vi.mock("@tanstack/react-query", () => ({ data: enabled === false ? undefined - : { profile: { heightCm: state.recordHeight } }, + : { + targets: [{ type: "BMI", range: state.bmiRange }], + profile: { heightCm: state.recordHeight }, + }, }), })); @@ -61,6 +65,7 @@ describe("/insights/bmi height gate", () => { state.viewerHeight = null; state.inSharedRecord = true; state.recordHeight = 118; + state.bmiRange = { min: 18.5, max: 24.9 }; }); it("reads the record's height inside somebody else's record", () => { @@ -77,4 +82,20 @@ describe("/insights/bmi height gate", () => { state.viewerHeight = null; expect(asksForHeight()).toBe(true); }); + + it("draws the adult bands only where the record's BMI range is named", () => { + const chartBands = () => { + renderToStaticMarkup(); + const { children } = shellProps.mock.calls.at(-1)![0] as { + children: Array<{ props?: { valueBands?: unknown } }>; + }; + return children.find( + (child) => child?.props && "valueBands" in child.props, + )?.props?.valueBands; + }; + expect(chartBands()).toBeDefined(); + // A child's record: the server names no BMI range. + state.bmiRange = null; + expect(chartBands()).toBeUndefined(); + }); }); diff --git a/src/app/insights/bmi/page.tsx b/src/app/insights/bmi/page.tsx index 30d5b1ce1..9cae6c68b 100644 --- a/src/app/insights/bmi/page.tsx +++ b/src/app/insights/bmi/page.tsx @@ -56,15 +56,21 @@ export default function InsightsBmiPage() { const { data: targets } = useQuery({ queryKey: queryKeys.insightsTargets(), queryFn: () => - apiGet<{ profile?: { heightCm: number | null } }>( - "/api/insights/targets", - ), - enabled: isAuthenticated && inSharedRecord, + apiGet<{ + targets: ReadonlyArray<{ type: string; range: unknown }>; + profile?: { heightCm: number | null }; + }>("/api/insights/targets"), + enabled: isAuthenticated, }); const heightCm = inSharedRecord ? (targets?.profile?.heightCm ?? null) : (user?.heightCm ?? null); const heightKnown = !inSharedRecord || targets !== undefined; + // The adult bands only where the server names the BMI range: it names none + // for a child's record, which they do not describe. + const bmiBands = targets?.targets.find((entry) => entry.type === "BMI")?.range + ? BMI_BANDS + : undefined; // v1.4.27 — BMI is derived from WEIGHT. When no weight readings // exist yet, the existing "set your height" branch can never compute @@ -140,7 +146,7 @@ export default function InsightsBmiPage() { colors={["var(--chart-5)"]} unit="kg/m²" valueMode="bmi" - valueBands={BMI_BANDS} + valueBands={bmiBands} compareBaseline={compareBaseline} userTimezone={user?.timezone} showDataTable diff --git a/src/app/insights/weight/__tests__/weight-band.test.ts b/src/app/insights/weight/__tests__/weight-band.test.ts index 0a4da57a5..a716b6a27 100644 --- a/src/app/insights/weight/__tests__/weight-band.test.ts +++ b/src/app/insights/weight/__tests__/weight-band.test.ts @@ -12,11 +12,22 @@ describe("selectWeightBandRange", () => { it("builds the WHO band from the record's height", () => { const range = selectWeightBandRange({ targets: [ - { type: "WEIGHT", range: { min: 25.8, max: 34.7 }, source: "WHO BMI" }, + { type: "WEIGHT", range: { min: 59.9, max: 81 }, source: "WHO BMI" }, ], - profile: { heightCm: 118 }, + profile: { heightCm: 180 }, }); - expect(range).toEqual(buildWeightRangeFromHeight(118)); + expect(range).toEqual(buildWeightRangeFromHeight(180)); + }); + + it("paints no adult band behind a child's weights", () => { + // A 118 cm child: the server names no WHO range for a record under 18, + // where the adult band read a normal 21 kg as underweight. + expect( + selectWeightBandRange({ + targets: [{ type: "WEIGHT", range: null, source: "WHO BMI" }], + profile: { heightCm: 118 }, + }), + ).toBeNull(); }); it("paints the record's own target with the override wings", () => { diff --git a/src/app/insights/weight/weight-band.ts b/src/app/insights/weight/weight-band.ts index 0c645f8c6..2f0756a0d 100644 --- a/src/app/insights/weight/weight-band.ts +++ b/src/app/insights/weight/weight-band.ts @@ -24,14 +24,16 @@ export interface WeightTargetsSlice { * record's own target and height, and the reference panel under the chart * reads the same payload. The record's own target (`source: "Custom"`) gets * the wings `getEffectiveRange` gives every override; otherwise the band is - * the height-derived WHO one, and with neither there is none. + * the height-derived WHO one. Where the server names no range there is none: + * no target and no height, or a child's record, which the adult WHO band does + * not describe. */ export function selectWeightBandRange( data: WeightTargetsSlice | null, ): TrafficRange | null { - if (!data) return null; - const weight = data.targets.find((entry) => entry.type === "WEIGHT"); - if (weight?.source === "Custom" && weight.range) { + const weight = data?.targets.find((entry) => entry.type === "WEIGHT"); + if (!data || !weight?.range) return null; + if (weight.source === "Custom") { return getEffectiveRange( "WEIGHT", { heightCm: null, dateOfBirth: null, gender: null }, diff --git a/src/components/custom-metrics/custom-metric-form.tsx b/src/components/custom-metrics/custom-metric-form.tsx index af37f237c..b1cd3ee1d 100644 --- a/src/components/custom-metrics/custom-metric-form.tsx +++ b/src/components/custom-metrics/custom-metric-form.tsx @@ -76,6 +76,17 @@ function metricFields(existing: CustomMetricDto | undefined) { }; } +/** + * The sentence for a refused save. A name clash is read by its code: a 409 + * can also be the first save still running under the same key. + */ +function saveRefusal(err: unknown, t: (key: string) => string): string { + return err instanceof ApiError && + err.meta?.errorCode === "customMetric.name_taken" + ? t("customMetrics.form.nameTaken") + : localizedApiError(err, t, "customMetrics.form.saveError"); +} + /** * v1.25.5 — define or edit a custom metric. * @@ -150,11 +161,7 @@ export function CustomMetricForm({ ); onSuccess?.(saved); } catch (err) { - setError( - err instanceof ApiError && err.status === 409 - ? t("customMetrics.form.nameTaken") - : localizedApiError(err, t, "customMetrics.form.saveError"), - ); + setError(saveRefusal(err, t)); } finally { setSubmitting(false); } diff --git a/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx b/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx index 28ab5a317..aa768cdd7 100644 --- a/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx +++ b/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx @@ -408,7 +408,7 @@ describe("submit invalidation — the quick-add is a capture-picker surface, not }); it("forces the inactive Today hero + dashboard reads to refetch after submit", async () => { - // The helper the submit path calls — asserted directly, same shape as + // The helper the submit path calls, asserted directly, same shape as // the take-all-due suite: the bundle fans out (N keys), then the two // daily reads are invalidated with `refetchType: "inactive"`, which is // what actually refetches them while unmounted. diff --git a/src/components/documents/__tests__/document-picker-sheet-cap.test.tsx b/src/components/documents/__tests__/document-picker-sheet-cap.test.tsx index a698cb5be..db8b0d9c4 100644 --- a/src/components/documents/__tests__/document-picker-sheet-cap.test.tsx +++ b/src/components/documents/__tests__/document-picker-sheet-cap.test.tsx @@ -37,7 +37,10 @@ vi.mock("@tanstack/react-query", () => ({ })); import { I18nProvider } from "@/lib/i18n/context"; -import { DocumentPickerSheet } from "../document-picker-sheet"; +import { + DocumentPickerCapNotice, + DocumentPickerSheet, +} from "../document-picker-sheet"; function render() { return renderToStaticMarkup( @@ -60,12 +63,22 @@ describe("DocumentPickerSheet", () => { it("says the list is cut, and how to reach the rest", () => { page.nextCursor = "d49"; const html = render(); - expect(html).toContain('data-slot="document-picker-capped"'); + expect(html).toContain('data-slot="list-cap-notice"'); expect(html).toContain("Showing the newest 50"); }); it("says nothing when every document is listed", () => { page.nextCursor = null; - expect(render()).not.toContain('data-slot="document-picker-capped"'); + expect(render()).not.toContain('data-slot="list-cap-notice"'); + }); + + it("asks for a narrower search, not a search, while one runs", () => { + const html = renderToStaticMarkup( + + + , + ); + expect(html).toContain("Narrow the search"); + expect(html).not.toContain("Search to find"); }); }); diff --git a/src/components/documents/document-picker-sheet.tsx b/src/components/documents/document-picker-sheet.tsx index a14908f5a..140750ccf 100644 --- a/src/components/documents/document-picker-sheet.tsx +++ b/src/components/documents/document-picker-sheet.tsx @@ -2,9 +2,9 @@ /** * The vault document picker sheet: a `ResponsiveSheet` (bottom sheet on - * phones) with a debounced search over the person's own vault — the same - * recall path as the vault page, one page (50) of matches with a line that - * says when more exist, the search input as the way to an older document — + * phones) with a debounced search over the person's own vault (the same + * recall path as the vault page, one page of 50 matches with a line that + * says when more exist, the search input as the way to an older document) * and one toggle row per document. * * Two pickers used to carry this whole body as copies: linking documents to @@ -18,6 +18,7 @@ import { Check, Search } from "lucide-react"; import { useEffect, useRef, useState, type ReactNode } from "react"; import { useDocumentDate } from "@/components/documents/use-document-date"; +import { ListCapNotice } from "@/components/ui/list-cap-notice"; import { DOCUMENT_KIND_ICONS } from "@/components/documents/document-kind-meta"; import { EmptyState } from "@/components/ui/empty-state"; import { Input } from "@/components/ui/input"; @@ -191,17 +192,40 @@ export function DocumentPickerSheet({ })} )} - {cut && !list.isError ? ( -

- {t("documents.picker.capped", { - count: String(documents.length), - })} -

- ) : null} + ); } + +/** + * The line under a picker list cut at one page: the search reaches the rest, + * and while one runs, a narrower one does. Shared by the vault picker and + * the Coach's attachment picker. + */ +export function DocumentPickerCapNotice({ + cut, + count, + searching, +}: { + cut: boolean; + count: number; + searching: boolean; +}) { + const { t } = useTranslations(); + return ( + + ); +} diff --git a/src/components/insights/__tests__/correlation-card.test.tsx b/src/components/insights/__tests__/correlation-card.test.tsx index c6d067910..dfb19997c 100644 --- a/src/components/insights/__tests__/correlation-card.test.tsx +++ b/src/components/insights/__tests__/correlation-card.test.tsx @@ -7,9 +7,9 @@ import type { CorrelationResult } from "@/lib/insights/correlations"; * v1.4.20 — `` rendering. * * Tests cover: - * 1. OK status renders title + interpretation + source chip + disabled CTA. + * 1. OK status renders title + interpretation + source chip. * 2. Insufficient status renders the empty-state instead. - * 3. CTA is `disabled` and exposes the "Coming soon" tooltip via `title`. + * 3. The card offers no placeholder action, only ones that work. * 4. Confidence chip surfaces high / moderate / low. * 5. Locale-aware copy (EN / DE). */ diff --git a/src/components/insights/coach-panel/__tests__/attachment-picker-cap.test.tsx b/src/components/insights/coach-panel/__tests__/attachment-picker-cap.test.tsx index a4d117b05..202477891 100644 --- a/src/components/insights/coach-panel/__tests__/attachment-picker-cap.test.tsx +++ b/src/components/insights/coach-panel/__tests__/attachment-picker-cap.test.tsx @@ -56,14 +56,14 @@ describe("AttachmentPicker", () => { it("says the list is cut, and how to reach the rest", () => { page.nextCursor = "d49"; const html = render(); - expect(html).toContain('data-slot="coach-attachment-picker-capped"'); + expect(html).toContain('data-slot="list-cap-notice"'); expect(html).toContain("Showing the newest 50"); }); it("says nothing when every document is listed", () => { page.nextCursor = null; const html = render(); - expect(html).not.toContain('data-slot="coach-attachment-picker-capped"'); + expect(html).not.toContain('data-slot="list-cap-notice"'); expect( html.match(/data-slot="coach-attachment-picker-item"/g), ).toHaveLength(3); diff --git a/src/components/insights/coach-panel/attachment-picker.tsx b/src/components/insights/coach-panel/attachment-picker.tsx index b86fd9fa1..0fefc9684 100644 --- a/src/components/insights/coach-panel/attachment-picker.tsx +++ b/src/components/insights/coach-panel/attachment-picker.tsx @@ -15,6 +15,7 @@ import { cn } from "@/lib/utils"; import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; import { DOCUMENT_KIND_ICONS } from "@/components/documents/document-kind-meta"; +import { DocumentPickerCapNotice } from "@/components/documents/document-picker-sheet"; import { QueryErrorRow } from "@/components/ui/query-error-row"; import { EmptyState } from "@/components/ui/empty-state"; @@ -266,16 +267,11 @@ export function AttachmentPicker({ })} )} - {cut && !list.isError ? ( -

- {t("documents.picker.capped", { - count: String(list.data?.documents.length ?? 0), - })} -

- ) : null} + ); diff --git a/src/components/measurements/__tests__/measurement-list-glucose-unit.test.tsx b/src/components/measurements/__tests__/measurement-list-glucose-unit.test.tsx index ed1d056da..0eec0f5f2 100644 --- a/src/components/measurements/__tests__/measurement-list-glucose-unit.test.tsx +++ b/src/components/measurements/__tests__/measurement-list-glucose-unit.test.tsx @@ -61,10 +61,15 @@ vi.mock("@/hooks/use-auth", () => ({ import { I18nProvider } from "@/lib/i18n/context"; import { MeasurementList } from "../measurement-list"; +import type { Measurement } from "../list/list-model"; import type { UnitDisplay } from "@/hooks/use-unit-display"; import { convertGlucose, toCanonicalMgdl } from "@/lib/glucose"; import { getUnitForType } from "@/lib/measurements/unit-map"; -import { editSeedValue, storedValueFor } from "../list/use-measurement-edit"; +import { + editSeedValue, + outOfRangeMessage, + storedValueFor, +} from "../list/use-measurement-edit"; import { rowDisplayFor } from "../list/use-measurement-row-display"; /** A metric reader on the given glucose unit, as the list's row display. */ @@ -140,3 +145,35 @@ describe("MeasurementList — blood-glucose display unit", () => { expect(storedValueFor(display, glucose(95.48), 95.48)).toBe(95.48); }); }); + +describe("the out-of-range sentence", () => { + const t = ((key: string, params?: Record) => + `${key} ${JSON.stringify(params)}`) as unknown as Parameters< + typeof outOfRangeMessage + >[2]; + const reading = (type: string, unit: string): Measurement => ({ + id: "m1", + type, + value: 1, + unit, + source: "MANUAL", + measuredAt: "2026-11-10T12:00:00.000Z", + notes: null, + }); + const shows = (unit: string) => ({ + rowDisplay: (m: { value: number }) => ({ value: m.value, unit }), + }); + + it("names the unit where the type has one", () => { + expect( + outOfRangeMessage(shows("kg"), reading("WEIGHT", "kg"), t, String), + ).toMatch(/^measurements\.valueOutOfRange \{.*"unit":"kg"/); + }); + + it("leaves the unit out for a score, which has no label", () => { + // "Enter a value between 0 and 10 ." read with a space before the stop. + expect( + outOfRangeMessage(shows(""), reading("PAIN_NRS", "score"), t, String), + ).toBe('measurements.valueOutOfRangeNoUnit {"min":"0","max":"10"}'); + }); +}); diff --git a/src/components/measurements/list/__tests__/row-actions.test.tsx b/src/components/measurements/list/__tests__/row-actions.test.tsx index e481a9427..db5bd6912 100644 --- a/src/components/measurements/list/__tests__/row-actions.test.tsx +++ b/src/components/measurements/list/__tests__/row-actions.test.tsx @@ -1,7 +1,7 @@ /** - * A computed score offers neither edit nor delete in the measurement list; - * a reading of the person's own, or one a device or provider sent, keeps - * both. + * A computed score offers neither edit nor delete in the measurement list, + * and no selection checkbox for the bulk delete; a reading of the person's + * own, or one a device or provider sent, keeps all three. */ import { describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; @@ -15,6 +15,8 @@ vi.mock("@/hooks/use-record-capabilities", () => ({ import { I18nProvider } from "@/lib/i18n/context"; import type { Measurement } from "../list-model"; +import { MeasurementCardList } from "../measurement-card-list"; +import { MeasurementTable } from "../measurement-table"; import { MeasurementRowActions, type MeasurementRowContext, @@ -73,3 +75,63 @@ describe("MeasurementRowActions", () => { }, ); }); + +describe("the row checkbox", () => { + const listRows: MeasurementRowContext = { + ...rows, + display: { + rowDisplay: (m: Measurement) => ({ value: m.value, unit: m.unit }), + rowIdentityAttrs: () => ({}), + } as unknown as MeasurementRowContext["display"], + }; + const reading = (id: string, source: string): Measurement => ({ + id, + type: "RECOVERY_SCORE", + value: 85, + unit: "score", + source, + measuredAt: "2026-11-10T12:00:00.000Z", + notes: null, + }); + const checkboxes = (html: string) => + html.split('aria-label="Select row"').length - 1; + + it("is offered for a reading and not for a computed score, on both layouts", () => { + const measurements = [reading("m1", "MANUAL"), reading("m2", "COMPUTED")]; + const wrap = (node: React.ReactNode) => + renderToStaticMarkup( + {node}, + ); + expect( + checkboxes( + wrap( + , + ), + ), + ).toBe(1); + expect( + checkboxes( + wrap( + {}, + } as unknown as Parameters[0]["filters"] + } + list={ + { + selectAll: "none", + pageIds: [], + onToggleSelectAll: () => {}, + } as unknown as Parameters[0]["list"] + } + />, + ), + ), + ).toBe(1); + }); +}); diff --git a/src/components/measurements/list/measurement-card-list.tsx b/src/components/measurements/list/measurement-card-list.tsx index ce5f90d09..fdc48cf75 100644 --- a/src/components/measurements/list/measurement-card-list.tsx +++ b/src/components/measurements/list/measurement-card-list.tsx @@ -18,6 +18,7 @@ import { import { DayDrillDown, SleepNightCaption } from "./day-drill-down"; import { MeasurementRowActions, + isServerComputed, rowState, typeLabel, type MeasurementRowContext, @@ -83,7 +84,7 @@ function MeasurementCard({ >
- {!isGrouped && canManage && ( + {!isGrouped && !isServerComputed(m) && canManage && ( // The checkbox owns its 44 px hit area through an `after` // hit-slop inside a plain layout `
`: a wrapper `