diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 443b3d55a..7dbc7be8a 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -13677,9 +13677,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. @@ -39700,8 +39700,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, @@ -39714,30 +39714,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, @@ -90608,8 +90608,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 13bcb27aa..72af59b85 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", @@ -5946,6 +5959,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", @@ -12480,7 +12494,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 6ba260ae3..e9a22bc8a 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", @@ -5946,6 +5959,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", @@ -12480,7 +12494,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 62128c707..6a78e4f48 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", @@ -5946,6 +5959,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", @@ -12480,7 +12494,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 f9885e3c5..a1605cce0 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", @@ -5946,6 +5959,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", @@ -12480,7 +12494,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 f78033c05..2bb134ad0 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", @@ -5946,6 +5959,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", @@ -12480,7 +12494,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 c62750df2..ef1b5cdca 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가 기록할 수 있는 것", @@ -5946,6 +5959,7 @@ "inProgress": "복용 이력 불러오기가 이미 진행 중이에요. 끝날 때까지 기다려 주세요.", "workerUnavailable": "백그라운드 작업자를 쓸 수 없어서 아무것도 불러오지 못했어요.", "failed": "불러오지 못했어요. 아무것도 저장되지 않았어요.", + "stillRunning": "불러오기가 평소보다 오래 걸리고 있어요. 아직 끝날 수 있으니 몇 분 뒤에 복용 기록을 다시 확인해 주세요.", "columnsHeading": "각 열의 역할", "jsonOnly": "JSON 전용", "previewLine": "읽은 행 {rows}개, 불러올 행 {queued}개, 거부된 행 {refused}개", @@ -12480,7 +12494,8 @@ "purgeConfirmBody": "대기 기간이 끝나기 전에 지금 삭제되며 복구할 수 없습니다." }, "picker": { - "capped": "최신 {count}개를 보여 줘요. 이전 문서는 검색으로 찾아 주세요." + "capped": "최신 {count}개를 보여 줘요. 이전 문서는 검색으로 찾아 주세요.", + "cappedSearching": "최근 검색 결과 {count}개를 보여 줘요. 이전 문서는 검색어를 좁혀서 찾아 주세요." } }, "selfDescription": { diff --git a/messages/nl.json b/messages/nl.json index da1aa2e27..c58a5138d 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", @@ -5946,6 +5959,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", @@ -12480,7 +12494,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 854b541fe..d252e10a8 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ć", @@ -5946,6 +5959,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}", @@ -12480,7 +12494,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 bf5265f6e..0126af071 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", @@ -5946,6 +5959,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", @@ -12480,7 +12494,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": { @@ -13411,7 +13426,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 d207f54f6..2ded3fb25 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", @@ -5946,6 +5959,7 @@ "inProgress": "Импорт истории доз уже выполняется. Дождитесь его завершения.", "workerUnavailable": "Фоновый обработчик недоступен, поэтому ничего не импортировано.", "failed": "Импорт не удался. Ничего не записано.", + "stillRunning": "Импорт идёт дольше обычного и ещё может завершиться. Проверьте свои приёмы через несколько минут.", "columnsHeading": "Что делает каждый столбец", "jsonOnly": "Только JSON", "previewLine": "Прочитано строк: {rows}, будет импортировано: {queued}, отклонено: {refused}", @@ -12480,7 +12494,8 @@ "purgeConfirmBody": "Он будет удалён сейчас, а не в конце периода ожидания, и его нельзя будет восстановить." }, "picker": { - "capped": "Показаны {count} самых новых. Более старый документ найдите через поиск." + "capped": "Показаны {count} самых новых. Более старый документ найдите через поиск.", + "cappedSearching": "Показаны {count} самых новых совпадений. Уточните поиск, чтобы найти более старый документ." } }, "selfDescription": { 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/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index f9771fe85..54d329d69 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -1,12 +1,12 @@ { "$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 84, - "total": 93834, + "total": 93833, "entries": { "app/medications/page-client.tsx": 839, "components/charts/mood-chart.tsx": 1166, "components/cycle/log-day-sheet.tsx": 1639, - "components/insights/coach-panel/chat-bubble.tsx": 822, + "components/insights/coach-panel/chat-bubble.tsx": 821, "components/insights/coach-panel/coach-conversation.tsx": 1278, "components/insights/coach-panel/use-coach.ts": 1486, "components/insights/derived/vitals-dashboard.tsx": 1033, @@ -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 7b03fc37e..a8b1b6796 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": 337, - "total": 109677, + "total": 109571, "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": 569, "app/auth/register/page.tsx :: RegisterPage": 285, @@ -53,20 +53,20 @@ "components/admin/api-token-overview-section.tsx :: ApiTokenOverviewSection": 212, "components/admin/app-log-preview-section.tsx :: AppLogPreviewSection": 341, "components/admin/central-codex-section.tsx :: CentralCodexSection": 228, - "components/admin/encryption-section.tsx :: EncryptionSection": 264, + "components/admin/encryption-section.tsx :: EncryptionSection": 261, "components/admin/general-settings-section.tsx :: GeneralSettingsSection": 220, - "components/admin/glitchtip-section.tsx :: GlitchtipSection": 233, + "components/admin/glitchtip-section.tsx :: GlitchtipSection": 231, "components/admin/host-metrics-chart.tsx :: HostMetricsChart": 232, "components/admin/invite-tokens-section.tsx :: InviteTokensSection": 525, "components/admin/login-overview-section.tsx :: LoginOverviewSection": 503, - "components/admin/pulse-day-mean-card.tsx :: PulseDayMeanCard": 305, - "components/admin/reminders-section.tsx :: RemindersSection": 309, + "components/admin/pulse-day-mean-card.tsx :: PulseDayMeanCard": 302, + "components/admin/reminders-section.tsx :: RemindersSection": 306, "components/admin/user-management-section.tsx :: UserManagementSection": 630, "components/admin/web-push-vapid-section.tsx :: WebPushVapidSection": 217, "components/auth/claim-profile.tsx :: ClaimProfile": 429, "components/auth/mfa-login-step.tsx :: MfaLoginStep": 217, "components/charts/compliance-heatmap.tsx :: ComplianceHeatmap": 325, - "components/charts/health-chart.tsx :: HealthChart": 470, + "components/charts/health-chart.tsx :: HealthChart": 462, "components/charts/medication-compliance-chart.tsx :: MedicationComplianceChart": 543, "components/charts/mood-chart.tsx :: MoodChart": 896, "components/charts/mood-heatmap.tsx :: MoodHeatmap": 296, @@ -75,7 +75,7 @@ "components/command-palette/command-palette.tsx :: PaletteBody": 333, "components/custom-metrics/custom-metric-chart.tsx :: CustomMetricChart": 228, "components/custom-metrics/custom-metric-detail.tsx :: CustomMetricDetail": 277, - "components/custom-metrics/custom-metric-form.tsx :: CustomMetricForm": 214, + "components/custom-metrics/custom-metric-form.tsx :: CustomMetricForm": 210, "components/custom-metrics/custom-metric-history-list.tsx :: CustomMetricHistoryList": 298, "components/cycle/bbt-chart.tsx :: BbtChart": 244, "components/cycle/cycle-calendar.tsx :: CycleCalendar": 292, @@ -99,10 +99,10 @@ "components/documents/sources/document-source-picker.tsx :: DocumentSourcePicker": 563, "components/illness/log-day-sheet.tsx :: LogDaySheet": 265, "components/illness/new-episode-sheet.tsx :: NewEpisodeSheet": 264, - "components/insights/coach-panel/chat-bubble.tsx :: ChatBubbleImpl": 419, + "components/insights/coach-panel/chat-bubble.tsx :: ChatBubbleImpl": 417, "components/insights/coach-panel/coach-conversation.tsx :: CoachConversation": 1002, "components/insights/coach-panel/coach-input.tsx :: CoachInput": 259, - "components/insights/coach-panel/coach-model-picker.tsx :: CoachModelPicker": 212, + "components/insights/coach-panel/coach-model-picker.tsx :: CoachModelPicker": 208, "components/insights/coach-panel/conversations-panel.tsx :: ConversationsPanel": 317, "components/insights/coach-panel/message-thread.tsx :: MessageThread": 390, "components/insights/coach-panel/sources-rail.tsx :: SourcesRail": 203, @@ -147,19 +147,19 @@ "components/measurements/measurement-form.tsx :: MeasurementForm": 496, "components/medications/detail/efficacy/efficacy-chart.tsx :: EfficacyChart": 361, "components/medications/detail/medication-detail-tabs.tsx :: MedicationDetailTabs": 767, - "components/medications/dose-history-add-dialog.tsx :: LedgerAddDialog": 223, + "components/medications/dose-history-add-dialog.tsx :: LedgerAddDialog": 221, "components/medications/dose-strength-curve.tsx :: DoseStrengthCurve": 266, "components/medications/glp1-medication-card.tsx :: Glp1MedicationCard": 405, "components/medications/injection-site-picker.tsx :: InjectionSitePicker": 287, "components/medications/intake-history-list-v2.tsx :: IntakeHistoryListV2": 403, - "components/medications/intake-import-dialog.tsx :: IntakeImportDialog": 208, + "components/medications/intake-import-dialog.tsx :: IntakeImportDialog": 201, "components/medications/log-intake-dialog.tsx :: LogIntakeDialog": 267, "components/medications/medication-card.tsx :: MedicationCard": 413, "components/medications/medication-table.tsx :: MedicationTableRowItem": 377, - "components/medications/scheduling/natural-language-extractor.tsx :: NaturalLanguageExtractor": 214, + "components/medications/scheduling/natural-language-extractor.tsx :: NaturalLanguageExtractor": 211, "components/medications/scheduling/schedule-history-timeline.tsx :: EraDialog": 219, "components/medications/scheduling/schedule-history-timeline.tsx :: ScheduleHistoryTimeline": 254, - "components/medications/sections/api-tokens-row.tsx :: ApiTokensRow": 296, + "components/medications/sections/api-tokens-row.tsx :: ApiTokensRow": 294, "components/medications/sections/composition-section.tsx :: CompositionEditor": 362, "components/medications/sections/inventory-dialogs.tsx :: AddInventoryDialog": 238, "components/medications/sections/inventory-section.tsx :: InventorySection": 368, @@ -167,12 +167,12 @@ "components/medications/wizard/medication-wizard-dialog.tsx :: WizardDialogShell": 516, "components/medications/wizard/steps/step3-dose.tsx :: Step3Dose": 222, "components/medications/wizard/steps/step8-summary.tsx :: Step8Summary": 230, - "components/mood/manage/tag-groups-card.tsx :: TagGroupsCard": 256, - "components/mood/manage/tag-manager-card.tsx :: TagManagerCard": 270, + "components/mood/manage/tag-groups-card.tsx :: TagGroupsCard": 252, + "components/mood/manage/tag-manager-card.tsx :: TagManagerCard": 261, "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, @@ -186,8 +186,8 @@ "components/settings/access/managed-profile-edit-form.tsx :: ManagedProfileEditFields": 208, "components/settings/access/managed-profile-guardians.tsx :: ManagedProfileGuardians": 234, "components/settings/account-section/index.tsx :: AccountSection": 659, - "components/settings/ai/codex-provider-form.tsx :: CodexProviderForm": 280, - "components/settings/ai/fallback-chain-card.tsx :: FallbackChainCard": 228, + "components/settings/ai/codex-provider-form.tsx :: CodexProviderForm": 270, + "components/settings/ai/fallback-chain-card.tsx :: FallbackChainCard": 224, "components/settings/ai/runtime-actions-row.tsx :: RuntimeActionsRow": 284, "components/settings/api-section.tsx :: ApiTokensCard": 269, "components/settings/coach-nudge-card.tsx :: CoachNudgeCard": 251, @@ -195,7 +195,7 @@ "components/settings/environment-section.tsx :: EnvironmentSection": 666, "components/settings/import-panel/apple-health-import-card.tsx :: AppleHealthImportCard": 298, "components/settings/import-panel/csv-import-card.tsx :: CsvImportCard": 212, - "components/settings/import-panel/dose-history-import-card.tsx :: DoseHistoryImportCard": 225, + "components/settings/import-panel/dose-history-import-card.tsx :: DoseHistoryImportCard": 222, "components/settings/import-panel/health-connect-import-card.tsx :: HealthConnectImportCard": 293, "components/settings/import-panel/record-csv-import-card.tsx :: RecordCsvImportSheet": 458, "components/settings/insights-pill-order-section.tsx :: InsightsPillOrderSection": 255, @@ -210,7 +210,7 @@ "components/settings/report-selection/selection-panel.tsx :: HealthRecordExportPanel": 370, "components/settings/score-section.tsx :: ScoreSection": 383, "components/settings/security-section/passkey-list-section.tsx :: PasskeyListSection": 395, - "components/settings/security-section/security-keys-card.tsx :: SecurityKeysCard": 288, + "components/settings/security-section/security-keys-card.tsx :: SecurityKeysCard": 286, "components/settings/security-section/totp-card.tsx :: TotpCard": 533, "components/settings/security-sessions-card.tsx :: SecuritySessionsCard": 245, "components/settings/settings-shell.tsx :: SettingsShell": 254, @@ -222,7 +222,7 @@ "components/symptoms/symptom-entry-form.tsx :: SymptomEntryForm": 317, "components/symptoms/symptoms-section.tsx :: SymptomsSection": 205, "components/targets/range-bar.tsx :: RangeBar": 220, - "components/targets/target-edit-sheet.tsx :: TargetEditSheetBody": 474, + "components/targets/target-edit-sheet.tsx :: TargetEditSheetBody": 473, "components/timeline/life-event-form.tsx :: LifeEventForm": 281, "components/timeline/timeline-chart.tsx :: ChartBody": 236, "components/timeline/timeline-chronicle.tsx :: TimelineChronicle": 246, @@ -331,8 +331,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 ef945bea3..4786f3b8b 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": 5459, + "total": 5457, "entries": { "__tests__/api/auth/login/mfa-branch.test.ts": 10, "__tests__/api/auth/mfa/verify.test.ts": 9, @@ -236,7 +236,7 @@ "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, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index 642303b6b..85cd2c071 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": 2884, - "total": 9191, + "total": 9190, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -355,7 +355,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, 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 4c5c12a33..fd016cf02 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -1553,7 +1553,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", @@ -2099,11 +2099,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: [], @@ -2305,7 +2300,7 @@ const ACTOR_ROUTES: Record = { * make, or a managed profile's guardian's, never an adult record's delegate's. * 276 -> 278. */ -const FROZEN_ENTRY_COUNT = 278; +const FROZEN_ENTRY_COUNT = 277; /** * 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 cc6db7390..04dc43c69 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 e0cb0a053..bec187da1 100644 --- a/src/components/custom-metrics/custom-metric-form.tsx +++ b/src/components/custom-metrics/custom-metric-form.tsx @@ -60,6 +60,17 @@ interface CustomMetricFormProps { footerSlot?: HTMLElement | null; } +/** + * 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. * @@ -132,11 +143,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 `