Merge remote-tracking branch 'origin/main' into release/v1.47.5

# Conflicts:
#	src/__tests__/baselines/source-file-lines.json
#	src/__tests__/baselines/source-function-lines.json
#	src/__tests__/baselines/test-as-never.json
#	src/__tests__/baselines/version-stamp-comments.json
#	src/__tests__/sharing-surface-guard.test.ts
This commit is contained in:
Marc-André Bombeck committed 2026-11-12 14:25:05 +01:00
commit 5ac998e4f0
112 files changed
+1816 -593

No files matched your search

+128
View File
@@ -2,6 +2,134 @@
## [Unreleased]
## [1.47.3] — 2026-11-12
### Changed
- **AI uses only the providers you left switched on.** When the provider
order saved under Settings > AI could not answer (say only a local
model was on and it was unreachable), several features quietly fell
back to the ChatGPT sign-in or to the operator's server key: the
briefing, the status notes, document reading and the Coach among
them. They now report that no provider is available, like the rest of
the app already did, and the cost lands where you expect it. An
account that never saved a provider order works as before, and the
OpenAI-compatible gateway you picked still answers unless you switched
it off in the order. If you run an instance and your people rely on
the server key, have them keep it switched on in their provider order.
The connection test now says "No AI provider configured" in that case
instead of a confusing validation message.
- **Turning AI off also ends the invitations to set it up.** With both
the Coach and AI analysis switched off, the "set up AI" hint, the
checklist row and the mention on the last setup screen no longer
appear. That setup screen now carries one line pointing to
Settings > AI instead of a large panel.
- **Blood pressure and resting pulse are judged against your own
target.** The charts have followed a target you set since #1261, but
the status notes, the overview, the morning briefing and the Coach
still compared your readings with the age standard. They now use your
target when you set one, and the age standard otherwise.
- **Coach plans show their metric by name.** A plan card read a raw key
such as `WEIGHT`; it now names the measurement in your language. The
nightly review still moves a finished plan to "Reviewed", but no
longer works out a verdict that no screen showed. The
`COACH_EXPERIMENT_VERDICT` setting is gone; remove it from your
environment if you set it.
- **The notification settings list only what HealthLog sends.**
"Unusual measurement", "Low adherence", "Withings sync failed" and
"Personal record" had switches, three of them on by default, but
nothing ever sent them. They are gone from the list. A failed Withings
sync still reaches admins as a system alert.
### Fixed
- **New entries show up in the day view and on the timeline right
away.** Symptoms, workouts, lab results, allergies, documents and
questionnaires appeared there only after a reload, also when added
through "Capture for this day". Confirming facts from a document and
turning free-text conditions or allergies into entries now update the
dashboard, Today, the day view and the timeline at once, and a
check-up marked done leaves the Today rail immediately.
- **Forms close as soon as the entry is stored.** Saving a measurement,
a mood, a workout, a dose, a vaccination, a visit, a symptom, an
illness episode or a life event kept the sheet open with emptied
fields and a spinner until every chart had reloaded. It now closes
right away and the charts catch up behind it. Bulk deletes and their
Undo do the same.
- **Tapping Save again after a timeout no longer stores the entry
twice.** Each opened form now sends the same request key until it is
saved, so the server recognises a repeat.
- **Errors say what went wrong, in your language.** An expired session,
a rate limit, a missing permission or a rejected value now say so
instead of "could not be saved", and many places that showed
the server's English text, among them the settings, mood tags,
medications, the Coach model picker and the admin pages, now show a
translated sentence. The measurement edit sheet marks an implausible
value on its field and names the accepted range in your unit. The
visit, vaccination, allergy, address-book and workout forms name the
actual problem, such as a removed doctor or a dose without a name. A
mistyped password while adding a passkey says so, instead of claiming
the session had ended.
- **Undo after deleting a vaccination, a visit or an address-book
entry.** The toast now offers it, like it does for illness episodes,
also when you delete two entries in a row.
- **A check-up marked done cannot be marked twice.** A quick second tap
on "Done", "Skip" or "Snooze" stored a second entry.
- **A managed person's health record carries their name.** The PDF,
its properties, the FHIR export and the share link showed the
technical login `managed-…` when no full name was stored. They now use
the display name, and so does an own account's report when no full
name is set. Initials in settings and in the Coach chat follow the
display name too.
- **Dates without a time land on their own day west of UTC.** For
people in the Americas the date of birth on the health record and the
share page showed the day before, and vaccinations sat on the previous
day in the day view, the calendar dots, the timeline, the visit
preparation, the health record and the share page; the first booster
reminder came a day early, also when a logged dose moved an existing
booster reminder. All of them now read the date as a date, and a date
of birth entered in the iPhone app is stored as a plain date too, so
it no longer depends on the hour it was typed.
The day view also stops inventing a clock time (01:00 or 13:00) for
vaccinations and allergy onsets.
- **Weight and BMI in someone else's record use that record.** In a
shared or managed record, the weight chart shaded the viewer's target
or a band from the viewer's height, the BMI page and chart checked the
viewer's height, and the target gear opened an editor that could not
save there. They now use the record on screen, and the gear is hidden
where it cannot work. Someone with read access to a record now sees
its weight band as well. The adult weight and BMI bands are no longer
drawn on a child's record, since they do not apply to children; the
values and charts stay. A weight target below 30 kg is accepted, so a
guardian can set one for a child.
- **"What changed since your last panel" sees small changes.** A move
from 0.004 to 0.009 read "no change", and some values printed with
fifteen decimals. Changes are now compared at the precision the lab
printed and shown in your language's notation.
- **The Medications module switched off means quiet.** Dose reminders,
missed-dose entries, low-stock alerts and the Telegram medication
commands kept running with the module off. They now wait until it is
switched back on, and nothing is lost meanwhile. A medication whose
course has ended no longer triggers low-stock alerts, and the Telegram
bot no longer offers it or records a dose for it. When two medications
share a name, the bot now picks the running one.
- **Medication reminders show the time in your 12- or 24-hour
setting.**
- **Computed scores no longer offer edit and delete.** Recovery, strain
and other computed rows in the measurement list showed a pencil that
led to an empty form and a bin, and "select all" swept them into a bulk
delete. They are now left out of both.
- **The cycle page takes "today" from your profile's time zone**, like
the rest of the app, instead of from the device.
- **The document pickers say when they show only the newest 50**, and
that search finds older ones.
### Removed
- The disabled "Try a 7-day experiment" button on correlation cards,
which never did anything.
- Three components nothing rendered, and their strings.
## [1.47.2] — 2026-11-11
### Added
+32 -32
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.47.2
version: 1.47.3
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -13698,9 +13698,9 @@ paths:
and the profile facts every band was resolved from. Pure compute over the record's own data — no provider
anywhere on the path. Stale-while-revalidate: past the fresh window the prior body serves immediately while one
background rebuild warms the cell, and a write hard-evicts the bucket so the account's own action lands on the
next read. Delegable at MANAGE level over the whole record: the walk spans vitals, sleep, medications, mood and
glucose, so a section-scoped grant is refused rather than served a filtered page. Not module-gated as a whole; a
tile whose data does not exist is simply absent. Cookie or Bearer auth."
next read. Delegable at READ level over the whole record, like the dashboard snapshot: the walk spans vitals,
sleep, medications, mood and glucose, so a section-scoped grant is refused rather than served a filtered page.
Not module-gated as a whole; a tile whose data does not exist is simply absent. Cookie or Bearer auth."
responses:
"200":
description: The target tiles and their page summary.
@@ -39722,8 +39722,8 @@ components:
anamnesis.auditC.invalid, anamnesis.auditC.notFound, anamnesis.auditC.rateLimited,
anamnesis.fact.conflict, anamnesis.fact.currentExists, anamnesis.fact.invalidValue,
anamnesis.lifeConfirm.invalid, anamnesis.lifeSections.invalid; auth — auth.account.deletion_pending,
auth.admin.required, auth.already_authenticated, auth.mfa.code_invalid, auth.missing,
auth.passkey.last_method, auth.refresh.invalid, auth.refresh.reuse, auth.refresh.revoked,
auth.admin.required, auth.already_authenticated, auth.mfa.already_active, auth.mfa.code_invalid,
auth.missing, auth.passkey.last_method, auth.refresh.invalid, auth.refresh.reuse, auth.refresh.revoked,
auth.reproof.failed, auth.reproof.required, auth.reproof.too_weak, auth.scope.insufficient,
auth.session.insecure_transport, auth.session.unavailable, auth.stepup.required, auth.token.expired,
auth.token.invalid; backup — backup.foreign_reference, backup.key.missing, backup.owner.exists,
@@ -39736,30 +39736,30 @@ components:
coach.fenced.attachmentConflict, coach.fenced.attachmentLimit, coach.fenced.attachmentNotFound,
coach.fenced.attachmentNotIndexed, coach.fenced.attachmentRequired, coach.fenced.attachmentUnavailable,
coach.fenced.invalid, coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json,
coach-prefs.body.invalid_shape; consent — consent.ai.confirmRequired, consent.ai.required; cycle —
cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid, cycle.calendar.range,
cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled,
cycle.feed.api_disabled, cycle.feed.cookie_only, cycle.feed.exists, cycle.feed.fertile_goal,
cycle.feed.none, cycle.feed.notFound, cycle.feed.rateLimited, cycle.period.invalid,
cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs — cycle-prefs.body.invalid_json,
cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json; data_wipe — data_wipe.copy_failed;
dawarich — dawarich.credentials_rejected, dawarich.home_place_unknown, dawarich.invalid,
dawarich.not_connected, dawarich.rate_limited, dawarich.unreachable; day — day.invalid; demo —
demo.readOnly; documents — documents.chat.conversationNotFound, documents.chat.invalid,
documents.inbound.alreadyConfirmed, documents.inbound.alreadyPartlyConfirmed,
documents.inbound.budgetExceeded, documents.inbound.decryptFailed, documents.inbound.encounterNotFound,
documents.inbound.episodeNotFound, documents.inbound.extractFailed, documents.inbound.factNotFound,
documents.inbound.factNotPending, documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge,
documents.inbound.fileType, documents.inbound.invalidMetadata, documents.inbound.invalidQuery,
documents.inbound.invalidUpdate, documents.inbound.localOcrDisabled, documents.inbound.notDeleted,
documents.inbound.notFound, documents.inbound.notIndexed, documents.inbound.outputLimit,
documents.inbound.pdfRendererUnavailable, documents.inbound.providerUnsupported,
documents.inbound.quotaExceeded, documents.inbound.rateLimited, documents.inbound.restoreDuplicate,
documents.inbound.restoreGone, documents.inbound.sourceAliasLimit, documents.inbound.uploadBusy,
documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound, documents.sources.authRefused,
documents.sources.badResponse, documents.sources.browserOnly, documents.sources.invalidAddress,
documents.sources.linkTargetNotFound, documents.sources.notConnected, documents.sources.notFound,
documents.sources.organizationRequired, documents.sources.originNotAllowed,
coach-prefs.body.invalid_shape; consent — consent.ai.confirmRequired, consent.ai.required; customMetric
— customMetric.name_taken; cycle — cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid,
cycle.calendar.range, cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid,
cycle.disabled, cycle.feed.api_disabled, cycle.feed.cookie_only, cycle.feed.exists,
cycle.feed.fertile_goal, cycle.feed.none, cycle.feed.notFound, cycle.feed.rateLimited,
cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs —
cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json;
data_wipe — data_wipe.copy_failed; dawarich — dawarich.credentials_rejected,
dawarich.home_place_unknown, dawarich.invalid, dawarich.not_connected, dawarich.rate_limited,
dawarich.unreachable; day — day.invalid; demo — demo.readOnly; documents —
documents.chat.conversationNotFound, documents.chat.invalid, documents.inbound.alreadyConfirmed,
documents.inbound.alreadyPartlyConfirmed, documents.inbound.budgetExceeded,
documents.inbound.decryptFailed, documents.inbound.encounterNotFound, documents.inbound.episodeNotFound,
documents.inbound.extractFailed, documents.inbound.factNotFound, documents.inbound.factNotPending,
documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, documents.inbound.fileType,
documents.inbound.invalidMetadata, documents.inbound.invalidQuery, documents.inbound.invalidUpdate,
documents.inbound.localOcrDisabled, documents.inbound.notDeleted, documents.inbound.notFound,
documents.inbound.notIndexed, documents.inbound.outputLimit, documents.inbound.pdfRendererUnavailable,
documents.inbound.providerUnsupported, documents.inbound.quotaExceeded, documents.inbound.rateLimited,
documents.inbound.restoreDuplicate, documents.inbound.restoreGone, documents.inbound.sourceAliasLimit,
documents.inbound.uploadBusy, documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound,
documents.sources.authRefused, documents.sources.badResponse, documents.sources.browserOnly,
documents.sources.invalidAddress, documents.sources.linkTargetNotFound, documents.sources.notConnected,
documents.sources.notFound, documents.sources.organizationRequired, documents.sources.originNotAllowed,
documents.sources.permissionMissing, documents.sources.rateLimited, documents.sources.redirected,
documents.sources.tokenRequired, documents.sources.unavailable, documents.sources.unreachable,
documents.sources.versionTooOld; encounter — encounter.invalid, encounter.link.invalid,
@@ -90687,8 +90687,8 @@ components:
type: array
items:
type: string
description: The event categories this account is shown a switch for, in declaration order — the list a client renders
rows for. A category nothing sends is not listed, and `preferences` carries no stored row for it.
description: "The event categories this account is shown a switch for, in declaration order: the list a client renders
rows for. A category nothing sends is not listed, and `preferences` carries no stored row for it."
eventDefaults:
type: object
propertyNames:
+19 -4
View File
@@ -142,11 +142,15 @@
"auth": {
"reproof": {
"required": "Bestätige dein aktuelles Passwort, um das zu ändern.",
"failed": "Die Bestätigung ist fehlgeschlagen. Es wurde nichts geändert."
"failed": "Die Bestätigung ist fehlgeschlagen. Es wurde nichts geändert.",
"too_weak": "Bestätige mit deinem zweiten Faktor oder einem Passkey. Bei diesem Konto reicht das Passwort allein nicht."
},
"missing": "Deine Sitzung ist abgelaufen, daher wurde nichts geändert. Melde dich erneut an, um weiterzumachen.",
"passkey": {
"last_method": "Dieser Passkey ist deine einzige Anmeldemöglichkeit. Lege zuerst ein Passwort fest oder füge einen weiteren Passkey hinzu."
},
"mfa": {
"already_active": "Für dieses Konto ist bereits eine Authenticator-App eingerichtet. Deaktiviere sie zuerst, um eine neue einzurichten."
}
},
"biomarkers": {
@@ -218,10 +222,18 @@
"workout": {
"batch": {
"invalid": "Prüfe die Angaben zum Training und versuche es erneut.",
"too_large": "Zu viele Trainings auf einmal. Schicke weniger auf einmal.",
"too_large": "Zu viele Trainings auf einmal. Schicke sie in kleineren Paketen.",
"payload_too_large": "Die Trainingsdaten sind zu groß, um sie auf einmal zu speichern.",
"source_not_permitted": "Diese Verbindung darf keine Trainings unter dieser Quelle speichern."
}
},
"ai": {
"provider": {
"none": "Es ist kein KI-Anbieter eingerichtet, daher wurde nichts gesendet. Richte einen in den KI-Einstellungen ein."
},
"budget": {
"exceeded": "Das KI-Budget für heute ist aufgebraucht, daher wurde nichts gesendet. Morgen steht es wieder bereit."
}
}
},
"doctorReport": {
@@ -1024,7 +1036,8 @@
"typeTrainingReadiness": "Trainingsbereitschaft",
"typeAcuteTrainingLoad": "Akute Trainingsbelastung",
"sourceGarmin": "Garmin",
"valueOutOfRange": "Gib einen Wert zwischen {min} und {max} {unit} ein."
"valueOutOfRange": "Gib einen Wert zwischen {min} und {max} {unit} ein.",
"valueOutOfRangeNoUnit": "Gib einen Wert zwischen {min} und {max} ein."
},
"metricCatalog": {
"title": "Was HealthLog erfassen kann",
@@ -5959,6 +5972,7 @@
"inProgress": "Ein Import des Einnahmeverlaufs läuft bereits. Auf sein Ende warten.",
"workerUnavailable": "Der Hintergrunddienst ist nicht verfügbar, es wurde nichts importiert.",
"failed": "Der Import ist fehlgeschlagen. Es wurde nichts geschrieben.",
"stillRunning": "Der Import dauert länger als üblich und kann noch fertig werden. Sieh dir deine Einnahmen in ein paar Minuten noch einmal an.",
"columnsHeading": "Was mit jeder Spalte passiert",
"jsonOnly": "nur JSON",
"previewLine": "{rows} Zeilen gelesen, {queued} würden importiert, {refused} abgewiesen",
@@ -12494,7 +12508,8 @@
"purgeConfirmBody": "Es wird jetzt entfernt statt am Ende der Wartezeit und lässt sich nicht wiederherstellen."
},
"picker": {
"capped": "Hier stehen die neuesten {count}. Ältere Dokumente findest du über die Suche."
"capped": "Hier stehen die neuesten {count}. Ältere Dokumente findest du über die Suche.",
"cappedSearching": "Hier stehen die neuesten {count} Treffer. Ältere findest du mit einer genaueren Suche."
}
},
"selfDescription": {
+20 -5
View File
@@ -142,11 +142,15 @@
"auth": {
"reproof": {
"required": "Confirm your current password to change this.",
"failed": "That did not verify. Nothing was changed."
"failed": "That did not verify. Nothing was changed.",
"too_weak": "Confirm with your second factor or a passkey. On this account a password alone is not enough."
},
"missing": "Your session has ended, so nothing was changed. Sign in again to continue.",
"passkey": {
"last_method": "This passkey is your only way to sign in. Set a password or add another passkey first."
},
"mfa": {
"already_active": "An authenticator app is already set up for this account. Disable it first to set up a new one."
}
},
"biomarkers": {
@@ -204,7 +208,7 @@
"encounter-not-found": "This visit no longer exists. Choose another or none.",
"custom-vaccine-not-found": "This vaccine no longer exists. Choose it again from the list.",
"not-found": "This vaccination is no longer available.",
"identity-required": "A dose needs a name: pick a vaccine from the list or type what the record says.",
"identity-required": "A dose needs a name: pick a vaccine from the list or type the name on your vaccination card.",
"booster-invalid": "Check the booster details and try again.",
"booster-no-antigen": "This dose names no vaccine from the catalogue, so no booster can be planned for it.",
"booster-out-of-scope": "Your access to this record does not cover planning a booster."
@@ -218,10 +222,18 @@
"workout": {
"batch": {
"invalid": "Check the workout details and try again.",
"too_large": "Too many workouts at once. Send fewer at a time.",
"too_large": "Too many workouts at once. Send them in smaller batches.",
"payload_too_large": "The workout data is too large to save in one go.",
"source_not_permitted": "This connection may not save workouts under that source."
}
},
"ai": {
"provider": {
"none": "No AI provider is set up, so nothing was sent to one. Set one up in the AI settings."
},
"budget": {
"exceeded": "Today's AI budget is used up, so nothing was sent. It renews tomorrow."
}
}
},
"doctorReport": {
@@ -1024,7 +1036,8 @@
"typeTrainingReadiness": "Training readiness",
"typeAcuteTrainingLoad": "Acute training load",
"sourceGarmin": "Garmin",
"valueOutOfRange": "Enter a value between {min} and {max} {unit}."
"valueOutOfRange": "Enter a value between {min} and {max} {unit}.",
"valueOutOfRangeNoUnit": "Enter a value between {min} and {max}."
},
"metricCatalog": {
"title": "What HealthLog can track",
@@ -5959,6 +5972,7 @@
"inProgress": "A dose-history import is already running. Wait for it to finish.",
"workerUnavailable": "The background worker is not available, so nothing was imported.",
"failed": "The import failed. Nothing was written.",
"stillRunning": "The import is taking longer than usual and may still finish. Look at your doses again in a few minutes.",
"columnsHeading": "What each column does",
"jsonOnly": "JSON only",
"previewLine": "{rows} rows read, {queued} would be imported, {refused} refused",
@@ -12494,7 +12508,8 @@
"purgeConfirmBody": "It is removed now instead of at the end of the waiting period, and cannot be restored."
},
"picker": {
"capped": "Showing the newest {count}. Search to find an older document."
"capped": "Showing the newest {count}. Search to find an older document.",
"cappedSearching": "Showing the newest {count} matches. Narrow the search to find an older one."
}
},
"selfDescription": {
+19 -4
View File
@@ -142,11 +142,15 @@
"auth": {
"reproof": {
"required": "Confirma tu contraseña actual para cambiar esto.",
"failed": "La verificación ha fallado. No se ha cambiado nada."
"failed": "La verificación ha fallado. No se ha cambiado nada.",
"too_weak": "Confírmalo con tu segundo factor o una clave de acceso. En esta cuenta no basta con la contraseña."
},
"missing": "Tu sesión ha caducado, así que no se ha cambiado nada. Inicia sesión de nuevo para continuar.",
"passkey": {
"last_method": "Esta clave de acceso es tu única forma de iniciar sesión. Primero establece una contraseña o añade otra clave de acceso."
},
"mfa": {
"already_active": "Esta cuenta ya tiene una aplicación de autenticación configurada. Desactívala primero para configurar una nueva."
}
},
"biomarkers": {
@@ -204,7 +208,7 @@
"encounter-not-found": "Esta cita ya no existe. Elige otra o ninguna.",
"custom-vaccine-not-found": "Esta vacuna ya no existe. Elígela de nuevo en la lista.",
"not-found": "Esta vacuna ya no está disponible.",
"identity-required": "Una dosis necesita un nombre: elige una vacuna de la lista o escribe lo que pone el registro.",
"identity-required": "Una dosis necesita un nombre: elige una vacuna de la lista o escribe lo que figura en la cartilla.",
"booster-invalid": "Comprueba los datos del refuerzo e inténtalo de nuevo.",
"booster-no-antigen": "Esta dosis no indica ninguna vacuna del catálogo, así que no se puede planificar un refuerzo.",
"booster-out-of-scope": "Tu acceso a este registro no permite planificar un refuerzo."
@@ -222,6 +226,14 @@
"payload_too_large": "Los datos del entrenamiento son demasiado grandes para guardarlos de una vez.",
"source_not_permitted": "Esta conexión no puede guardar entrenamientos con esa fuente."
}
},
"ai": {
"provider": {
"none": "No hay ningún proveedor de IA configurado, así que no se ha enviado nada. Configura uno en los ajustes de IA."
},
"budget": {
"exceeded": "El presupuesto de IA de hoy se ha agotado, así que no se ha enviado nada. Se renueva mañana."
}
}
},
"doctorReport": {
@@ -1024,7 +1036,8 @@
"typeTrainingReadiness": "Preparación para entrenar",
"typeAcuteTrainingLoad": "Carga de entrenamiento aguda",
"sourceGarmin": "Garmin",
"valueOutOfRange": "Introduce un valor entre {min} y {max} {unit}."
"valueOutOfRange": "Introduce un valor entre {min} y {max} {unit}.",
"valueOutOfRangeNoUnit": "Introduce un valor entre {min} y {max}."
},
"metricCatalog": {
"title": "Lo que HealthLog puede registrar",
@@ -5959,6 +5972,7 @@
"inProgress": "Ya hay una importación de historial en curso. Espera a que termine.",
"workerUnavailable": "El proceso en segundo plano no está disponible, así que no se importó nada.",
"failed": "La importación falló. No se escribió nada.",
"stillRunning": "La importación está tardando más de lo habitual y aún puede terminar. Vuelve a mirar tus dosis dentro de unos minutos.",
"columnsHeading": "Qué hace cada columna",
"jsonOnly": "solo JSON",
"previewLine": "{rows} filas leídas, {queued} se importarían, {refused} rechazadas",
@@ -12494,7 +12508,8 @@
"purgeConfirmBody": "Se elimina ahora en lugar de al final del periodo de espera y no se puede restaurar."
},
"picker": {
"capped": "Se muestran los {count} más recientes. Usa la búsqueda para encontrar un documento más antiguo."
"capped": "Se muestran los {count} más recientes. Usa la búsqueda para encontrar un documento más antiguo.",
"cappedSearching": "Se muestran las {count} coincidencias más recientes. Afina la búsqueda para encontrar una más antigua."
}
},
"selfDescription": {
+19 -4
View File
@@ -142,11 +142,15 @@
"auth": {
"reproof": {
"required": "Confirmez votre mot de passe actuel pour modifier ceci.",
"failed": "La vérification a échoué. Rien n'a été modifié."
"failed": "La vérification a échoué. Rien n'a été modifié.",
"too_weak": "Confirmez avec votre second facteur ou une clé d’accès. Sur ce compte, le mot de passe seul ne suffit pas."
},
"missing": "Votre session a expiré, rien n'a donc été modifié. Reconnectez-vous pour continuer.",
"passkey": {
"last_method": "Cette clé d’accès est votre seul moyen de connexion. Définissez d’abord un mot de passe ou ajoutez une autre clé d’accès."
},
"mfa": {
"already_active": "Une application d'authentification est déjà configurée pour ce compte. Désactivez-la d'abord pour en configurer une nouvelle."
}
},
"biomarkers": {
@@ -218,10 +222,18 @@
"workout": {
"batch": {
"invalid": "Vérifiez les informations de l'entraînement et réessayez.",
"too_large": "Trop d'entraînements à la fois. Envoyez-en moins à la fois.",
"too_large": "Trop d'entraînements à la fois. Envoyez-les par lots plus petits.",
"payload_too_large": "Les données d'entraînement sont trop volumineuses pour être enregistrées en une fois.",
"source_not_permitted": "Cette connexion n'a pas le droit d'enregistrer des entraînements sous cette source."
}
},
"ai": {
"provider": {
"none": "Aucun fournisseur d’IA n’est configuré, rien n’a donc été envoyé. Configurez-en un dans les réglages IA."
},
"budget": {
"exceeded": "Le budget IA du jour est épuisé, rien n’a donc été envoyé. Il se renouvelle demain."
}
}
},
"doctorReport": {
@@ -1024,7 +1036,8 @@
"typeTrainingReadiness": "Disponibilité à l'entraînement",
"typeAcuteTrainingLoad": "Charge d'entraînement aiguë",
"sourceGarmin": "Garmin",
"valueOutOfRange": "Saisissez une valeur entre {min} et {max} {unit}."
"valueOutOfRange": "Saisissez une valeur entre {min} et {max} {unit}.",
"valueOutOfRangeNoUnit": "Saisissez une valeur entre {min} et {max}."
},
"metricCatalog": {
"title": "Ce que HealthLog peut suivre",
@@ -5959,6 +5972,7 @@
"inProgress": "Un import d'historique est déjà en cours. Attendez qu'il se termine.",
"workerUnavailable": "Le service d'arrière-plan est indisponible, rien n'a été importé.",
"failed": "L'import a échoué. Rien n'a été écrit.",
"stillRunning": "L'import prend plus de temps que d'habitude et peut encore aboutir. Revenez voir vos prises dans quelques minutes.",
"columnsHeading": "Ce que devient chaque colonne",
"jsonOnly": "JSON uniquement",
"previewLine": "{rows} lignes lues, {queued} seraient importées, {refused} refusées",
@@ -12494,7 +12508,8 @@
"purgeConfirmBody": "Il est supprimé maintenant plutôt qu'à la fin du délai d'attente et ne peut pas être restauré."
},
"picker": {
"capped": "Les {count} plus récents sont affichés. Utilisez la recherche pour trouver un document plus ancien."
"capped": "Les {count} plus récents sont affichés. Utilisez la recherche pour trouver un document plus ancien.",
"cappedSearching": "Les {count} résultats les plus récents sont affichés. Affinez la recherche pour en trouver un plus ancien."
}
},
"selfDescription": {
+19 -4
View File
@@ -142,11 +142,15 @@
"auth": {
"reproof": {
"required": "Conferma la password attuale per modificarlo.",
"failed": "La verifica non è riuscita. Non è stato modificato nulla."
"failed": "La verifica non è riuscita. Non è stato modificato nulla.",
"too_weak": "Conferma con il secondo fattore o una passkey. Per questo account la sola password non basta."
},
"missing": "La tua sessione è scaduta, quindi non è stato modificato nulla. Accedi di nuovo per continuare.",
"passkey": {
"last_method": "Questa passkey è il tuo unico modo per accedere. Imposta prima una password o aggiungi un’altra passkey."
},
"mfa": {
"already_active": "Per questo account è già configurata un'app di autenticazione. Disattivala prima di configurarne una nuova."
}
},
"biomarkers": {
@@ -218,10 +222,18 @@
"workout": {
"batch": {
"invalid": "Controlla i dati dell'allenamento e riprova.",
"too_large": "Troppi allenamenti in una volta. Inviane meno alla volta.",
"too_large": "Troppi allenamenti in una volta. Inviali in gruppi più piccoli.",
"payload_too_large": "I dati dell'allenamento sono troppo grandi per salvarli in una volta.",
"source_not_permitted": "Questa connessione non può salvare allenamenti con questa fonte."
}
},
"ai": {
"provider": {
"none": "Nessun provider di IA è configurato, quindi non è stato inviato nulla. Configurane uno nelle impostazioni IA."
},
"budget": {
"exceeded": "Il budget IA di oggi è esaurito, quindi non è stato inviato nulla. Si rinnova domani."
}
}
},
"doctorReport": {
@@ -1024,7 +1036,8 @@
"typeTrainingReadiness": "Prontezza all'allenamento",
"typeAcuteTrainingLoad": "Carico di allenamento acuto",
"sourceGarmin": "Garmin",
"valueOutOfRange": "Inserisci un valore tra {min} e {max} {unit}."
"valueOutOfRange": "Inserisci un valore tra {min} e {max} {unit}.",
"valueOutOfRangeNoUnit": "Inserisci un valore tra {min} e {max}."
},
"metricCatalog": {
"title": "Cosa può monitorare HealthLog",
@@ -5959,6 +5972,7 @@
"inProgress": "Un'importazione dello storico è già in corso. Attendi che finisca.",
"workerUnavailable": "Il processo in background non è disponibile, quindi non è stato importato nulla.",
"failed": "L'importazione è fallita. Non è stato scritto nulla.",
"stillRunning": "L'importazione richiede più tempo del solito e potrebbe ancora completarsi. Ricontrolla le tue assunzioni tra qualche minuto.",
"columnsHeading": "Cosa fa ogni colonna",
"jsonOnly": "solo JSON",
"previewLine": "{rows} righe lette, {queued} da importare, {refused} rifiutate",
@@ -12494,7 +12508,8 @@
"purgeConfirmBody": "Viene rimosso ora anziché alla fine del periodo di attesa e non può essere ripristinato."
},
"picker": {
"capped": "Sono mostrati i {count} più recenti. Usa la ricerca per trovare un documento più vecchio."
"capped": "Sono mostrati i {count} più recenti. Usa la ricerca per trovare un documento più vecchio.",
"cappedSearching": "Sono mostrati i {count} risultati più recenti. Restringi la ricerca per trovarne uno più vecchio."
}
},
"selfDescription": {
+22 -7
View File
@@ -142,11 +142,15 @@
"auth": {
"reproof": {
"required": "변경하려면 현재 비밀번호를 확인하세요.",
"failed": "확인에 실패했습니다. 아무것도 변경되지 않았습니다."
"failed": "확인에 실패했습니다. 아무것도 변경되지 않았습니다.",
"too_weak": "2단계 인증 수단이나 패스키로 확인하세요. 이 계정에서는 비밀번호만으로는 충분하지 않아요."
},
"missing": "세션이 만료되어 아무것도 변경되지 않았어요. 계속하려면 다시 로그인해 주세요.",
"passkey": {
"last_method": "이 패스키가 유일한 로그인 수단이에요. 먼저 비밀번호를 설정하거나 패스키를 하나 더 추가하세요."
},
"mfa": {
"already_active": "이 계정에는 이미 인증 앱이 설정되어 있어요. 새로 설정하려면 먼저 해제하세요."
}
},
"biomarkers": {
@@ -194,14 +198,14 @@
},
"encounter": {
"invalid": "진료 정보를 확인하고 다시 시도해 주세요.",
"practitioner-not-found": "이 병원 또는 의사가 더 이상 없어요. 다른 항목을 고르거나 선택하지 마세요.",
"reminder-not-found": "이 건강검진이 더 이상 없어요. 다른 검진을 고르거나 선택하지 마세요.",
"practitioner-not-found": "이 병원 또는 의사가 더 이상 없어요. 다른 항목을 고르거나 '지정 안 함'을 선택해 주세요.",
"reminder-not-found": "이 건강검진이 더 이상 없어요. 다른 검진을 고르거나 선택을 비워 두세요.",
"reminder-conflict": "예정된 진료는 아직 이루어지지 않았기 때문에 건강검진을 완료할 수 없어요."
},
"vaccination": {
"invalid": "예방접종 정보를 확인하고 다시 시도해 주세요.",
"practitioner-not-found": "이 병원 또는 의사가 더 이상 없어요. 다른 항목을 고르거나 선택하지 마세요.",
"encounter-not-found": "이 진료가 더 이상 없어요. 다른 진료를 고르거나 선택하지 마세요.",
"practitioner-not-found": "이 병원 또는 의사가 더 이상 없어요. 다른 항목을 고르거나 '지정 안 함'을 선택해 주세요.",
"encounter-not-found": "이 진료가 더 이상 없어요. 다른 진료를 고르거나 선택을 비워 두세요.",
"custom-vaccine-not-found": "이 백신이 더 이상 없어요. 목록에서 다시 골라 주세요.",
"not-found": "이 예방접종 기록은 더 이상 없어요.",
"identity-required": "접종에는 이름이 필요해요. 목록에서 백신을 고르거나 기록에 적힌 이름을 입력하세요.",
@@ -222,6 +226,14 @@
"payload_too_large": "운동 데이터가 너무 커서 한 번에 저장할 수 없어요.",
"source_not_permitted": "이 연결로는 해당 출처의 운동을 저장할 수 없어요."
}
},
"ai": {
"provider": {
"none": "설정된 AI 제공자가 없어 아무것도 보내지 않았어요. AI 설정에서 하나를 설정하세요."
},
"budget": {
"exceeded": "오늘의 AI 예산을 모두 사용해서 아무것도 보내지 않았어요. 내일 다시 채워져요."
}
}
},
"doctorReport": {
@@ -1024,7 +1036,8 @@
"typeTrainingReadiness": "트레이닝 준비도",
"typeAcuteTrainingLoad": "급성 훈련 부하",
"sourceGarmin": "Garmin",
"valueOutOfRange": "{min}에서 {max} {unit} 사이의 값을 입력해 주세요."
"valueOutOfRange": "{min}에서 {max} {unit} 사이의 값을 입력해 주세요.",
"valueOutOfRangeNoUnit": "{min}에서 {max} 사이의 값을 입력해 주세요."
},
"metricCatalog": {
"title": "HealthLog가 기록할 수 있는 것",
@@ -5959,6 +5972,7 @@
"inProgress": "복용 이력 불러오기가 이미 진행 중이에요. 끝날 때까지 기다려 주세요.",
"workerUnavailable": "백그라운드 작업자를 쓸 수 없어서 아무것도 불러오지 못했어요.",
"failed": "불러오지 못했어요. 아무것도 저장되지 않았어요.",
"stillRunning": "불러오기가 평소보다 오래 걸리고 있어요. 아직 끝날 수 있으니 몇 분 뒤에 복용 기록을 다시 확인해 주세요.",
"columnsHeading": "각 열의 역할",
"jsonOnly": "JSON 전용",
"previewLine": "읽은 행 {rows}개, 불러올 행 {queued}개, 거부된 행 {refused}개",
@@ -12494,7 +12508,8 @@
"purgeConfirmBody": "대기 기간이 끝나기 전에 지금 삭제되며 복구할 수 없습니다."
},
"picker": {
"capped": "최신 {count}개를 보여 줘요. 이전 문서는 검색으로 찾아 주세요."
"capped": "최신 {count}개를 보여 줘요. 이전 문서는 검색으로 찾아 주세요.",
"cappedSearching": "최근 검색 결과 {count}개를 보여 줘요. 이전 문서는 검색어를 좁혀서 찾아 주세요."
}
},
"selfDescription": {
+19 -4
View File
@@ -142,11 +142,15 @@
"auth": {
"reproof": {
"required": "Bevestig je huidige wachtwoord om dit te wijzigen.",
"failed": "De verificatie is mislukt. Er is niets gewijzigd."
"failed": "De verificatie is mislukt. Er is niets gewijzigd.",
"too_weak": "Bevestig met je tweede factor of een passkey. Voor dit account is alleen je wachtwoord niet genoeg."
},
"missing": "Je sessie is verlopen, dus er is niets gewijzigd. Meld je opnieuw aan om verder te gaan.",
"passkey": {
"last_method": "Deze passkey is je enige manier om in te loggen. Stel eerst een wachtwoord in of voeg nog een passkey toe."
},
"mfa": {
"already_active": "Voor dit account is al een authenticator-app ingesteld. Schakel die eerst uit om een nieuwe in te stellen."
}
},
"biomarkers": {
@@ -204,7 +208,7 @@
"encounter-not-found": "Dit bezoek bestaat niet meer. Kies een ander of geen.",
"custom-vaccine-not-found": "Dit vaccin bestaat niet meer. Kies het opnieuw uit de lijst.",
"not-found": "Deze vaccinatie is niet meer beschikbaar.",
"identity-required": "Een dosis heeft een naam nodig: kies een vaccin uit de lijst of typ wat er in het boekje staat.",
"identity-required": "Een dosis heeft een naam nodig: kies een vaccin uit de lijst of typ wat er op je vaccinatiebewijs staat.",
"booster-invalid": "Controleer de gegevens van de booster en probeer het opnieuw.",
"booster-no-antigen": "Deze dosis noemt geen vaccin uit de catalogus, dus er kan geen booster worden gepland.",
"booster-out-of-scope": "Je toegang tot dit dossier dekt het plannen van een booster niet."
@@ -222,6 +226,14 @@
"payload_too_large": "De trainingsgegevens zijn te groot om in één keer op te slaan.",
"source_not_permitted": "Deze koppeling mag geen trainingen onder die bron opslaan."
}
},
"ai": {
"provider": {
"none": "Er is geen AI-provider ingesteld, dus er is niets verzonden. Stel er een in bij de AI-instellingen."
},
"budget": {
"exceeded": "Het AI-budget voor vandaag is op, dus er is niets verzonden. Morgen is het er weer."
}
}
},
"doctorReport": {
@@ -1024,7 +1036,8 @@
"typeTrainingReadiness": "Trainingsgereedheid",
"typeAcuteTrainingLoad": "Acute trainingsbelasting",
"sourceGarmin": "Garmin",
"valueOutOfRange": "Vul een waarde in tussen {min} en {max} {unit}."
"valueOutOfRange": "Vul een waarde in tussen {min} en {max} {unit}.",
"valueOutOfRangeNoUnit": "Vul een waarde in tussen {min} en {max}."
},
"metricCatalog": {
"title": "Wat HealthLog kan bijhouden",
@@ -5959,6 +5972,7 @@
"inProgress": "Er loopt al een import van doseringsgeschiedenis. Wacht tot die klaar is.",
"workerUnavailable": "De achtergrondverwerking is niet beschikbaar, dus er is niets geïmporteerd.",
"failed": "De import is mislukt. Er is niets opgeslagen.",
"stillRunning": "De import duurt langer dan normaal en kan nog afronden. Kijk over een paar minuten opnieuw naar je innames.",
"columnsHeading": "Wat elke kolom doet",
"jsonOnly": "Alleen JSON",
"previewLine": "{rows} rijen gelezen, {queued} zouden worden geïmporteerd, {refused} geweigerd",
@@ -12494,7 +12508,8 @@
"purgeConfirmBody": "Het wordt nu verwijderd in plaats van aan het einde van de wachttijd en kan niet worden hersteld."
},
"picker": {
"capped": "De nieuwste {count} staan hier. Zoek om een ouder document te vinden."
"capped": "De nieuwste {count} staan hier. Zoek om een ouder document te vinden.",
"cappedSearching": "De nieuwste {count} resultaten staan hier. Verfijn de zoekopdracht om een ouder document te vinden."
}
},
"selfDescription": {
+23 -8
View File
@@ -142,11 +142,15 @@
"auth": {
"reproof": {
"required": "Potwierdź obecne hasło, aby to zmienić.",
"failed": "Weryfikacja nie powiodła się. Nic nie zostało zmienione."
"failed": "Weryfikacja nie powiodła się. Nic nie zostało zmienione.",
"too_weak": "Potwierdź drugim składnikiem lub kluczem dostępu. Na tym koncie samo hasło nie wystarczy."
},
"missing": "Twoja sesja wygasła, więc nic nie zostało zmienione. Zaloguj się ponownie, aby kontynuować.",
"passkey": {
"last_method": "Ten klucz dostępu to Twój jedyny sposób logowania. Najpierw ustaw hasło albo dodaj kolejny klucz dostępu."
},
"mfa": {
"already_active": "To konto ma już skonfigurowaną aplikację uwierzytelniającą. Najpierw ją wyłącz, aby skonfigurować nową."
}
},
"biomarkers": {
@@ -194,17 +198,17 @@
},
"encounter": {
"invalid": "Sprawdź dane wizyty i spróbuj ponownie.",
"practitioner-not-found": "Ten gabinet lub lekarz już nie istnieje. Wybierz inny albo żaden.",
"reminder-not-found": "To badanie już nie istnieje. Wybierz inne albo żadne.",
"practitioner-not-found": "Ten gabinet lub lekarz już nie istnieje. Wybierz inny wpis albo „Nie podano”.",
"reminder-not-found": "To badanie już nie istnieje. Wybierz inne albo pozostaw pole puste.",
"reminder-conflict": "Zaplanowana wizyta nie może zamknąć badania, bo jeszcze się nie odbyła."
},
"vaccination": {
"invalid": "Sprawdź dane szczepienia i spróbuj ponownie.",
"practitioner-not-found": "Ten gabinet lub lekarz już nie istnieje. Wybierz inny albo żaden.",
"encounter-not-found": "Ta wizyta już nie istnieje. Wybierz inną albo żadną.",
"practitioner-not-found": "Ten gabinet lub lekarz już nie istnieje. Wybierz inny wpis albo „Nie podano”.",
"encounter-not-found": "Ta wizyta już nie istnieje. Wybierz inną albo pozostaw pole puste.",
"custom-vaccine-not-found": "Ta szczepionka już nie istnieje. Wybierz ją ponownie z listy.",
"not-found": "To szczepienie nie jest już dostępne.",
"identity-required": "Dawka potrzebuje nazwy: wybierz szczepionkę z listy albo wpisz to, co widnieje w dokumentacji.",
"identity-required": "Dawka potrzebuje nazwy: wybierz szczepionkę z listy albo wpisz to, co podano w karcie szczepień.",
"booster-invalid": "Sprawdź dane dawki przypominającej i spróbuj ponownie.",
"booster-no-antigen": "Ta dawka nie wskazuje szczepionki z katalogu, więc nie da się zaplanować dawki przypominającej.",
"booster-out-of-scope": "Twój dostęp do tej kartoteki nie obejmuje planowania dawki przypominającej."
@@ -222,6 +226,14 @@
"payload_too_large": "Dane treningu są za duże, by zapisać je za jednym razem.",
"source_not_permitted": "To połączenie nie może zapisywać treningów pod tym źródłem."
}
},
"ai": {
"provider": {
"none": "Nie skonfigurowano dostawcy AI, więc nic nie zostało wysłane. Skonfiguruj go w ustawieniach AI."
},
"budget": {
"exceeded": "Dzisiejszy budżet AI został wykorzystany, więc nic nie zostało wysłane. Odnowi się jutro."
}
}
},
"doctorReport": {
@@ -1024,7 +1036,8 @@
"typeTrainingReadiness": "Gotowość do treningu",
"typeAcuteTrainingLoad": "Ostre obciążenie treningowe",
"sourceGarmin": "Garmin",
"valueOutOfRange": "Wpisz wartość od {min} do {max} {unit}."
"valueOutOfRange": "Wpisz wartość od {min} do {max} {unit}.",
"valueOutOfRangeNoUnit": "Wpisz wartość od {min} do {max}."
},
"metricCatalog": {
"title": "Co HealthLog potrafi śledzić",
@@ -5959,6 +5972,7 @@
"inProgress": "Import historii dawek już trwa. Poczekaj, aż się zakończy.",
"workerUnavailable": "Proces w tle jest niedostępny, więc nic nie zostało zaimportowane.",
"failed": "Import nie udał się. Nic nie zostało zapisane.",
"stillRunning": "Import trwa dłużej niż zwykle i może się jeszcze zakończyć. Sprawdź swoje dawki ponownie za kilka minut.",
"columnsHeading": "Co się dzieje z każdą kolumną",
"jsonOnly": "tylko JSON",
"previewLine": "Odczytane wiersze: {rows}, do zaimportowania: {queued}, odrzucone: {refused}",
@@ -12494,7 +12508,8 @@
"purgeConfirmBody": "Zostanie usunięty teraz zamiast po okresie oczekiwania i nie będzie można go przywrócić."
},
"picker": {
"capped": "Widać {count} najnowszych. Starszy dokument znajdziesz przez wyszukiwanie."
"capped": "Widać {count} najnowszych. Starszy dokument znajdziesz przez wyszukiwanie.",
"cappedSearching": "Widać {count} najnowszych wyników. Zawęź wyszukiwanie, aby znaleźć starszy."
}
},
"selfDescription": {
+21 -6
View File
@@ -142,11 +142,15 @@
"auth": {
"reproof": {
"required": "Confirme sua senha atual para alterar isto.",
"failed": "A verificação não foi concluída. Nada foi alterado."
"failed": "A verificação não foi concluída. Nada foi alterado.",
"too_weak": "Confirme com seu segundo fator ou uma chave de acesso. Nesta conta, só a senha não basta."
},
"missing": "Sua sessão terminou, então nada foi alterado. Entre novamente para continuar.",
"passkey": {
"last_method": "Esta chave de acesso é sua única forma de entrar. Defina uma senha ou adicione outra chave de acesso primeiro."
},
"mfa": {
"already_active": "Esta conta já tem um aplicativo autenticador configurado. Desative-o primeiro para configurar um novo."
}
},
"biomarkers": {
@@ -195,8 +199,8 @@
"encounter": {
"invalid": "Verifique os dados da consulta e tente novamente.",
"practitioner-not-found": "Esta clínica ou médico não existe mais. Escolha outro ou nenhum.",
"reminder-not-found": "Este check não existe mais. Escolha outro ou nenhum.",
"reminder-conflict": "Uma consulta planejada não pode fechar um check, porque ainda não aconteceu."
"reminder-not-found": "Este check-up não existe mais. Escolha outro ou nenhum.",
"reminder-conflict": "Uma consulta planejada não pode fechar um check-up, porque ainda não aconteceu."
},
"vaccination": {
"invalid": "Verifique os dados da vacina e tente novamente.",
@@ -222,6 +226,14 @@
"payload_too_large": "Os dados do treino são grandes demais para salvar de uma vez.",
"source_not_permitted": "Esta conexão não pode salvar treinos com essa origem."
}
},
"ai": {
"provider": {
"none": "Nenhum provedor de IA está configurado, então nada foi enviado. Configure um nas configurações de IA."
},
"budget": {
"exceeded": "O orçamento de IA de hoje acabou, então nada foi enviado. Ele é renovado amanhã."
}
}
},
"doctorReport": {
@@ -1024,7 +1036,8 @@
"typeTrainingReadiness": "Prontidão para treinar",
"typeAcuteTrainingLoad": "Carga de treino aguda",
"sourceGarmin": "Garmin",
"valueOutOfRange": "Informe um valor entre {min} e {max} {unit}."
"valueOutOfRange": "Informe um valor entre {min} e {max} {unit}.",
"valueOutOfRangeNoUnit": "Informe um valor entre {min} e {max}."
},
"metricCatalog": {
"title": "O que o HealthLog pode acompanhar",
@@ -5959,6 +5972,7 @@
"inProgress": "Uma importação de histórico de doses já está em andamento. Espere ela terminar.",
"workerUnavailable": "O worker em segundo plano não está disponível, então nada foi importado.",
"failed": "A importação falhou. Nada foi gravado.",
"stillRunning": "A importação está demorando mais que o normal e ainda pode terminar. Confira suas doses de novo em alguns minutos.",
"columnsHeading": "O que cada coluna faz",
"jsonOnly": "Somente JSON",
"previewLine": "{rows} linhas lidas, {queued} seriam importadas, {refused} recusadas",
@@ -12494,7 +12508,8 @@
"purgeConfirmBody": "Ele é removido agora, e não no fim do período de espera, e não pode ser restaurado."
},
"picker": {
"capped": "Mostrando os {count} mais recentes. Use a busca para encontrar um documento mais antigo."
"capped": "Mostrando os {count} mais recentes. Use a busca para encontrar um documento mais antigo.",
"cappedSearching": "Mostrando os {count} resultados mais recentes. Refine a busca para encontrar um mais antigo."
}
},
"selfDescription": {
@@ -13425,7 +13440,7 @@
"created": "Consulta salva",
"updated": "Consulta atualizada",
"saveFailed": "Não foi possível salvar a consulta.",
"checkupNotClosed": "Consulta salva. O check permanece aberto; seu acesso não cobre isso.",
"checkupNotClosed": "Consulta salva. O check-up permanece aberto; seu acesso não cobre isso.",
"status": {
"planned": "Planejada",
"done": "Comparecida",
+23 -8
View File
@@ -142,11 +142,15 @@
"auth": {
"reproof": {
"required": "Подтвердите текущий пароль, чтобы изменить это.",
"failed": "Не подтверждено. Ничего не изменено."
"failed": "Не подтверждено. Ничего не изменено.",
"too_weak": "Подтвердите вторым фактором или ключом доступа. Для этого аккаунта одного пароля недостаточно."
},
"missing": "Сеанс завершён, поэтому ничего не изменено. Войдите снова, чтобы продолжить.",
"passkey": {
"last_method": "Этот ключ доступа остаётся вашим единственным способом входа. Сначала задайте пароль или добавьте ещё один ключ доступа."
},
"mfa": {
"already_active": "Для этого аккаунта уже настроено приложение-аутентификатор. Сначала выключите его, чтобы настроить новое."
}
},
"biomarkers": {
@@ -194,14 +198,14 @@
},
"encounter": {
"invalid": "Проверьте данные визита и попробуйте снова.",
"practitioner-not-found": "Этой клиники или врача больше нет. Выберите другую запись или никакую.",
"reminder-not-found": "Этого осмотра больше нет. Выберите другой или никакой.",
"practitioner-not-found": "Этой клиники или врача больше нет. Выберите другую запись или «Не указано».",
"reminder-not-found": "Этого осмотра больше нет. Выберите другой или оставьте поле пустым.",
"reminder-conflict": "Запланированный визит не может закрыть осмотр, потому что он ещё не состоялся."
},
"vaccination": {
"invalid": "Проверьте данные вакцинации и попробуйте снова.",
"practitioner-not-found": "Этой клиники или врача больше нет. Выберите другую запись или никакую.",
"encounter-not-found": "Этого визита больше нет. Выберите другой или никакой.",
"practitioner-not-found": "Этой клиники или врача больше нет. Выберите другую запись или «Не указано».",
"encounter-not-found": "Этого визита больше нет. Выберите другой или оставьте поле пустым.",
"custom-vaccine-not-found": "Этой вакцины больше нет. Выберите её в списке ещё раз.",
"not-found": "Этой вакцинации больше нет.",
"identity-required": "У дозы должно быть название: выберите вакцину из списка или введите то, что указано в записи.",
@@ -218,10 +222,18 @@
"workout": {
"batch": {
"invalid": "Проверьте данные тренировки и попробуйте снова.",
"too_large": "Слишком много тренировок за раз. Отправляйте меньше за один раз.",
"too_large": "Слишком много тренировок за раз. Отправляйте их небольшими частями.",
"payload_too_large": "Данные тренировки слишком велики, чтобы сохранить их за один раз.",
"source_not_permitted": "Это подключение не может сохранять тренировки с таким источником."
}
},
"ai": {
"provider": {
"none": "Поставщик ИИ не настроен, поэтому ничего не отправлено. Настройте его в настройках ИИ."
},
"budget": {
"exceeded": "Бюджет ИИ на сегодня исчерпан, поэтому ничего не отправлено. Он обновится завтра."
}
}
},
"doctorReport": {
@@ -1024,7 +1036,8 @@
"typeTrainingReadiness": "Готовность к тренировке",
"typeAcuteTrainingLoad": "Острая тренировочная нагрузка",
"sourceGarmin": "Garmin",
"valueOutOfRange": "Введите значение от {min} до {max} {unit}."
"valueOutOfRange": "Введите значение от {min} до {max} {unit}.",
"valueOutOfRangeNoUnit": "Введите значение от {min} до {max}."
},
"metricCatalog": {
"title": "Что умеет отслеживать HealthLog",
@@ -5959,6 +5972,7 @@
"inProgress": "Импорт истории доз уже выполняется. Дождитесь его завершения.",
"workerUnavailable": "Фоновый обработчик недоступен, поэтому ничего не импортировано.",
"failed": "Импорт не удался. Ничего не записано.",
"stillRunning": "Импорт идёт дольше обычного и ещё может завершиться. Проверьте свои приёмы через несколько минут.",
"columnsHeading": "Что делает каждый столбец",
"jsonOnly": "Только JSON",
"previewLine": "Прочитано строк: {rows}, будет импортировано: {queued}, отклонено: {refused}",
@@ -12494,7 +12508,8 @@
"purgeConfirmBody": "Он будет удалён сейчас, а не в конце периода ожидания, и его нельзя будет восстановить."
},
"picker": {
"capped": "Показаны {count} самых новых. Более старый документ найдите через поиск."
"capped": "Показаны {count} самых новых. Более старый документ найдите через поиск.",
"cappedSearching": "Показаны {count} самых новых совпадений. Уточните поиск, чтобы найти более старый документ."
}
},
"selfDescription": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.47.2",
"version": "1.47.3",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
-7
View File
@@ -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.
+6 -5
View File
@@ -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")
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.47.2";
/* @sw-version-fallback */ "v1.47.3";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
@@ -1,7 +1,7 @@
{
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 84,
"total": 93809,
"total": 93815,
"entries": {
"app/medications/page-client.tsx": 839,
"components/charts/mood-chart.tsx": 1166,
@@ -82,7 +82,7 @@
"lib/search/search-records.ts": 996,
"lib/sharing/grants.ts": 806,
"lib/support/report.ts": 903,
"lib/telegram-webhook-handlers.ts": 1313,
"lib/telegram-webhook-handlers.ts": 1319,
"lib/validations/inbound-documents.ts": 1018,
"lib/withings/sync-sleep.ts": 884,
"lib/withings/sync.ts": 933,
@@ -1,7 +1,7 @@
{
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 336,
"total": 109383,
"total": 109357,
"entries": {
"app/achievements/page.tsx :: AchievementsPage": 206,
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275,
@@ -36,7 +36,7 @@
"app/api/nutrients/batch/route.ts :: postBatch": 304,
"app/api/onboarding/complete/route.ts :: completeNeedsFlow": 218,
"app/api/record-settings/[family]/route.ts :: apiHandler(arg) #2": 262,
"app/api/user/ai-provider/route.ts :: apiHandler(arg) #2": 250,
"app/api/user/ai-provider/route.ts :: apiHandler(arg) #2": 224,
"app/api/withings/callback/route.ts :: apiHandler(arg)": 247,
"app/auth/login/page.tsx :: LoginPage": 567,
"app/auth/register/page.tsx :: RegisterPage": 285,
@@ -171,7 +171,7 @@
"components/mood/mood-form.tsx :: MoodForm": 514,
"components/mood/mood-list.tsx :: MoodList": 1109,
"components/mood/mood-tag-picker.tsx :: MoodTagPicker": 257,
"components/onboarding/confirm-screen.tsx :: ConfirmScreen": 255,
"components/onboarding/confirm-screen.tsx :: ConfirmScreen": 251,
"components/onboarding/getting-started-checklist.tsx :: GettingStartedChecklist": 423,
"components/onboarding/tour.tsx :: OnboardingTour": 544,
"components/practitioners/practitioner-list.tsx :: PractitionerList": 225,
@@ -330,8 +330,8 @@
"lib/record-activity/activity-verb.ts :: recordActivityVerbLine": 261,
"lib/strava/sync.ts :: runStravaSync": 219,
"lib/support/report.ts :: collectSupportReportFacts": 332,
"lib/targets/build-response.ts :: buildTargetsResponse": 330,
"lib/targets/vitals-builder.ts :: buildVitalTargets": 324,
"lib/targets/build-response.ts :: buildTargetsResponse": 332,
"lib/targets/vitals-builder.ts :: buildVitalTargets": 326,
"lib/telegram-webhook-handlers.ts :: handleCallback": 582,
"lib/telegram-webhook-handlers.ts :: handleTextMessage": 322,
"lib/withings/sync-activity.ts :: syncUserActivity": 377,
+18 -18
View File
@@ -1,7 +1,7 @@
{
"$comment": "as never casts in tests, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.",
"count": 523,
"total": 5458,
"total": 5456,
"entries": {
"__tests__/api/auth/login/mfa-branch.test.ts": 10,
"__tests__/api/auth/mfa/verify.test.ts": 9,
@@ -33,8 +33,8 @@
"app/api/admin/settings/module-availability/__tests__/route.test.ts": 13,
"app/api/admin/settings/web-push-vapid/generate/__tests__/route.test.ts": 4,
"app/api/admin/status/__tests__/route.test.ts": 3,
"app/api/admin/users/__tests__/route.test.ts": 3,
"app/api/admin/users/[id]/__tests__/route.test.ts": 8,
"app/api/admin/users/__tests__/route.test.ts": 3,
"app/api/ai/test/__tests__/route.test.ts": 26,
"app/api/analytics/__tests__/route.test.ts": 25,
"app/api/auth/claim/__tests__/route.test.ts": 13,
@@ -55,24 +55,24 @@
"app/api/auth/passkey/login-verify/__tests__/native-token.test.ts": 7,
"app/api/auth/passkey/login-verify/__tests__/oidc-only.test.ts": 4,
"app/api/auth/passkey/login-verify/__tests__/refusals.test.ts": 6,
"app/api/auth/passkeys/__tests__/route.test.ts": 3,
"app/api/auth/passkeys/[id]/__tests__/route.test.ts": 17,
"app/api/auth/passkeys/__tests__/route.test.ts": 3,
"app/api/auth/password/__tests__/route.test.ts": 5,
"app/api/auth/register/__tests__/route.test.ts": 25,
"app/api/biomarkers/__tests__/error-contract.test.ts": 3,
"app/api/biomarkers/[id]/__tests__/delete-record-auth.test.ts": 3,
"app/api/biomarkers/[id]/__tests__/route.test.ts": 23,
"app/api/biomarkers/__tests__/error-contract.test.ts": 3,
"app/api/coach/plans/__tests__/route.test.ts": 23,
"app/api/coach/reminders/__tests__/route.test.ts": 15,
"app/api/coach/suggested-actions/__tests__/route.test.ts": 7,
"app/api/consent/ai/__tests__/route.test.ts": 10,
"app/api/consent/ai/latest/__tests__/route.test.ts": 42,
"app/api/consent/ai/web/__tests__/route.test.ts": 6,
"app/api/custom-metrics/__tests__/route.test.ts": 14,
"app/api/custom-metrics/[id]/__tests__/route.test.ts": 23,
"app/api/custom-metrics/[id]/entries/__tests__/note-encryption.test.ts": 13,
"app/api/custom-metrics/[id]/entries/__tests__/route.test.ts": 12,
"app/api/custom-metrics/[id]/entries/__tests__/soft-delete-restore.test.ts": 6,
"app/api/custom-metrics/__tests__/route.test.ts": 14,
"app/api/cycle/__tests__/external-id-floor.test.ts": 9,
"app/api/cycle/calendar/__tests__/day-entries.test.ts": 9,
"app/api/cycle/calendar/__tests__/verdict-round-trip.test.ts": 26,
@@ -85,9 +85,8 @@
"app/api/dashboard/summary/__tests__/module-gate.test.ts": 6,
"app/api/dashboard/summary/__tests__/route.test.ts": 86,
"app/api/dashboard/widgets/__tests__/route.test.ts": 49,
"app/api/devices/__tests__/route.test.ts": 32,
"app/api/devices/[id]/__tests__/route.test.ts": 10,
"app/api/documents/inbound/__tests__/route.test.ts": 62,
"app/api/devices/__tests__/route.test.ts": 32,
"app/api/documents/inbound/[id]/__tests__/route.test.ts": 56,
"app/api/documents/inbound/[id]/chat/__tests__/route.test.ts": 42,
"app/api/documents/inbound/[id]/extract/__tests__/route.test.ts": 38,
@@ -96,6 +95,7 @@
"app/api/documents/inbound/[id]/suggest/__tests__/route.test.ts": 12,
"app/api/documents/inbound/[id]/summary/__tests__/route.test.ts": 31,
"app/api/documents/inbound/[id]/thumbnail/__tests__/route.test.ts": 5,
"app/api/documents/inbound/__tests__/route.test.ts": 62,
"app/api/documents/inbound/layout/__tests__/route.test.ts": 11,
"app/api/documents/inbound/reindex/__tests__/route.test.ts": 9,
"app/api/export/__tests__/measurement-streaming.test.ts": 9,
@@ -118,8 +118,8 @@
"app/api/illness/episodes/[id]/day-logs/__tests__/route.test.ts": 8,
"app/api/illness/episodes/[id]/restore/__tests__/route.test.ts": 9,
"app/api/import/__tests__/route.test.ts": 47,
"app/api/import/apple-health-export/__tests__/route.test.ts": 8,
"app/api/import/apple-health-export/[jobId]/status/__tests__/route.test.ts": 4,
"app/api/import/apple-health-export/__tests__/route.test.ts": 8,
"app/api/import/csv/__tests__/route.test.ts": 4,
"app/api/import/health-connect-export/__tests__/route.test.ts": 2,
"app/api/ingest/medication/__tests__/route.test.ts": 28,
@@ -134,14 +134,14 @@
"app/api/insights/correlations/__tests__/route.test.ts": 12,
"app/api/insights/derived/__tests__/route.test.ts": 12,
"app/api/insights/derived/batch/__tests__/route.test.ts": 21,
"app/api/insights/ecg/__tests__/route.test.ts": 5,
"app/api/insights/ecg/[id]/__tests__/route.test.ts": 5,
"app/api/insights/ecg/__tests__/route.test.ts": 5,
"app/api/insights/generate/__tests__/route.test.ts": 54,
"app/api/insights/layout/__tests__/route.test.ts": 23,
"app/api/insights/metric-status/__tests__/route.test.ts": 7,
"app/api/insights/mood-status/__tests__/route.test.ts": 10,
"app/api/insights/patterns/__tests__/route.test.ts": 1,
"app/api/insights/patterns/[id]/__tests__/route.test.ts": 4,
"app/api/insights/patterns/__tests__/route.test.ts": 1,
"app/api/insights/pregenerate/__tests__/route.test.ts": 4,
"app/api/insights/provider-chain/__tests__/put.test.ts": 4,
"app/api/insights/provider-chain/__tests__/route.test.ts": 13,
@@ -163,12 +163,12 @@
"app/api/mcp/oauth/__tests__/token.test.ts": 14,
"app/api/mcp/tokens/__tests__/route.test.ts": 3,
"app/api/measurement-categories/__tests__/route.test.ts": 3,
"app/api/measurements/[id]/__tests__/route.test.ts": 8,
"app/api/measurements/__tests__/arrival-wiring.test.ts": 7,
"app/api/measurements/__tests__/group-by-day.test.ts": 10,
"app/api/measurements/__tests__/put-duplicate-timestamp.test.ts": 9,
"app/api/measurements/__tests__/range-aggregation-route.test.ts": 10,
"app/api/measurements/__tests__/zod-multi-issue.test.ts": 2,
"app/api/measurements/[id]/__tests__/route.test.ts": 8,
"app/api/measurements/batch/__tests__/aggregated-hr-wire.test.ts": 9,
"app/api/measurements/batch/__tests__/duplicate-prefilter.test.ts": 1,
"app/api/measurements/batch/__tests__/hr-bucket-observability.test.ts": 3,
@@ -185,16 +185,16 @@
"app/api/measurements/series-batch/__tests__/route.test.ts": 10,
"app/api/measurements/series/__tests__/route.test.ts": 96,
"app/api/measurements/series/__tests__/unknown-query-param-open.test.ts": 6,
"app/api/medications/__tests__/route.test.ts": 43,
"app/api/medications/[id]/__tests__/route.test.ts": 52,
"app/api/medications/[id]/compliance/__tests__/route.test.ts": 18,
"app/api/medications/[id]/dose-history/__tests__/route.test.ts": 15,
"app/api/medications/[id]/glp1/__tests__/route.test.ts": 47,
"app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts": 63,
"app/api/medications/[id]/intake/__tests__/route.test.ts": 45,
"app/api/medications/[id]/intake/__tests__/source-provenance.test.ts": 12,
"app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts": 63,
"app/api/medications/[id]/intake/import/__tests__/route.test.ts": 9,
"app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts": 24,
"app/api/medications/__tests__/route.test.ts": 43,
"app/api/medications/compliance/__tests__/route.test.ts": 10,
"app/api/medications/extract/__tests__/route.test.ts": 37,
"app/api/medications/intake/__tests__/route.test.ts": 59,
@@ -202,14 +202,14 @@
"app/api/medications/intake/bulk/__tests__/route.test.ts": 68,
"app/api/medications/intake/dose-history-import/__tests__/route.test.ts": 7,
"app/api/medications/layout/__tests__/route.test.ts": 17,
"app/api/mental-health/assessments/__tests__/route.test.ts": 12,
"app/api/mental-health/assessments/[id]/__tests__/route.test.ts": 7,
"app/api/mental-health/assessments/__tests__/route.test.ts": 12,
"app/api/meta/capabilities/__tests__/route.test.ts": 1,
"app/api/monitoring/glitchtip/__tests__/route.test.ts": 2,
"app/api/monitoring/umami-script/__tests__/route.test.ts": 2,
"app/api/monitoring/umami/[...path]/__tests__/route.test.ts": 4,
"app/api/mood-entries/__tests__/route.test.ts": 6,
"app/api/mood-entries/[id]/__tests__/route.test.ts": 8,
"app/api/mood-entries/__tests__/route.test.ts": 6,
"app/api/mood-entries/bulk-delete/__tests__/route.test.ts": 10,
"app/api/mood-entries/bulk/__tests__/route.test.ts": 17,
"app/api/mood-entries/restore/__tests__/route.test.ts": 11,
@@ -236,14 +236,14 @@
"app/api/sleep/rhythm/__tests__/route.test.ts": 6,
"app/api/sync/changes/__tests__/module-gate.test.ts": 7,
"app/api/sync/state/__tests__/route.test.ts": 8,
"app/api/telegram/webhook/__tests__/route.test.ts": 43,
"app/api/telegram/webhook/__tests__/route.test.ts": 41,
"app/api/tokens/documents/__tests__/route.test.ts": 25,
"app/api/tokens/measurements/__tests__/route.test.ts": 25,
"app/api/tokens/workouts/__tests__/route.test.ts": 25,
"app/api/user/ai-provider/__tests__/legacy-private-host.test.ts": 2,
"app/api/user/ai-provider/__tests__/route.test.ts": 12,
"app/api/user/avatar/__tests__/route.test.ts": 13,
"app/api/user/avatar/[id]/__tests__/route.test.ts": 5,
"app/api/user/avatar/__tests__/route.test.ts": 13,
"app/api/user/profile/__tests__/route.test.ts": 29,
"app/api/user/thresholds/__tests__/route.test.ts": 7,
"app/api/whoop/callback/__tests__/route.test.ts": 2,
@@ -251,8 +251,8 @@
"app/api/withings/callback/__tests__/route.test.ts": 9,
"app/api/withings/connect/__tests__/route.test.ts": 4,
"app/api/withings/credentials/__tests__/route.test.ts": 4,
"app/api/withings/webhook/__tests__/route.test.ts": 26,
"app/api/withings/webhook/[token]/__tests__/route.test.ts": 8,
"app/api/withings/webhook/__tests__/route.test.ts": 26,
"app/api/workouts/__tests__/batch-create.test.ts": 13,
"app/api/workouts/__tests__/canonical-dedup.test.ts": 20,
"app/api/workouts/batch/__tests__/unstable-external-id.test.ts": 4,
@@ -1,7 +1,7 @@
{
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
"count": 2872,
"total": 9153,
"total": 9152,
"entries": {
"__tests__/account-payload-consumer-guard.test.ts": 2,
"__tests__/acting-account-boundary-guard.test.ts": 7,
@@ -172,9 +172,9 @@
"app/api/auth/refresh/route.ts": 2,
"app/api/auth/register/__tests__/route.test.ts": 5,
"app/api/auth/register/route.ts": 10,
"app/api/biomarkers/__tests__/error-contract.test.ts": 1,
"app/api/biomarkers/[id]/__tests__/route.test.ts": 2,
"app/api/biomarkers/[id]/route.ts": 6,
"app/api/biomarkers/__tests__/error-contract.test.ts": 1,
"app/api/biomarkers/route.ts": 4,
"app/api/c/[token]/unlock/__tests__/route.test.ts": 1,
"app/api/c/[token]/unlock/route.ts": 1,
@@ -184,12 +184,12 @@
"app/api/coach/about-me/questions/__tests__/route-module-gate.test.ts": 1,
"app/api/coach/about-me/questions/route.ts": 3,
"app/api/coach/about-me/route.ts": 4,
"app/api/coach/plans/__tests__/route.test.ts": 1,
"app/api/coach/plans/[id]/route.ts": 2,
"app/api/coach/plans/__tests__/route.test.ts": 1,
"app/api/coach/plans/route.ts": 1,
"app/api/coach/reminder-suggestions/route.ts": 1,
"app/api/coach/reminders/__tests__/route.test.ts": 1,
"app/api/coach/reminders/[id]/route.ts": 1,
"app/api/coach/reminders/__tests__/route.test.ts": 1,
"app/api/coach/reminders/route.ts": 1,
"app/api/coach/suggested-actions/__tests__/route.test.ts": 1,
"app/api/coach/suggested-actions/route.ts": 1,
@@ -197,9 +197,9 @@
"app/api/consent/ai/latest/__tests__/route.test.ts": 3,
"app/api/consent/ai/latest/route.ts": 3,
"app/api/consent/ai/route.ts": 3,
"app/api/custom-metrics/[id]/entries/[entryId]/route.ts": 2,
"app/api/custom-metrics/[id]/entries/__tests__/note-encryption.test.ts": 1,
"app/api/custom-metrics/[id]/entries/__tests__/soft-delete-restore.test.ts": 1,
"app/api/custom-metrics/[id]/entries/[entryId]/route.ts": 2,
"app/api/custom-metrics/[id]/entries/route.ts": 4,
"app/api/custom-metrics/[id]/route.ts": 1,
"app/api/custom-metrics/route.ts": 1,
@@ -209,8 +209,8 @@
"app/api/cycle/day-logs/[id]/route.ts": 4,
"app/api/cycle/day-logs/route.ts": 1,
"app/api/cycle/period/route.ts": 4,
"app/api/cycle/symptoms/custom/__tests__/custom-crud.test.ts": 1,
"app/api/cycle/symptoms/custom/[key]/route.ts": 1,
"app/api/cycle/symptoms/custom/__tests__/custom-crud.test.ts": 1,
"app/api/cycle/symptoms/custom/route.ts": 3,
"app/api/dashboard/chart-overlay-prefs/__tests__/route.test.ts": 1,
"app/api/dashboard/chart-overlay-prefs/route.ts": 2,
@@ -220,11 +220,10 @@
"app/api/dashboard/summary/route.ts": 5,
"app/api/dashboard/widgets/__tests__/route.test.ts": 4,
"app/api/dashboard/widgets/route.ts": 14,
"app/api/devices/__tests__/route.test.ts": 1,
"app/api/devices/[id]/__tests__/route.test.ts": 1,
"app/api/devices/[id]/route.ts": 1,
"app/api/devices/__tests__/route.test.ts": 1,
"app/api/devices/route.ts": 4,
"app/api/documents/inbound/__tests__/route.test.ts": 1,
"app/api/documents/inbound/[id]/__tests__/route.test.ts": 1,
"app/api/documents/inbound/[id]/chat/__tests__/route.test.ts": 2,
"app/api/documents/inbound/[id]/chat/route.ts": 1,
@@ -239,6 +238,7 @@
"app/api/documents/inbound/[id]/suggest/route.ts": 2,
"app/api/documents/inbound/[id]/summary/route.ts": 2,
"app/api/documents/inbound/[id]/thumbnail/route.ts": 1,
"app/api/documents/inbound/__tests__/route.test.ts": 1,
"app/api/documents/inbound/reindex/route.ts": 1,
"app/api/documents/inbound/route.ts": 2,
"app/api/documents/inbound/usage/route.ts": 1,
@@ -257,10 +257,10 @@
"app/api/export/mood/route.ts": 2,
"app/api/family-history/[id]/route.ts": 2,
"app/api/family-history/route.ts": 1,
"app/api/fhir/Patient/$everything/route.ts": 1,
"app/api/fhir/__tests__/observation-series.test.ts": 1,
"app/api/fhir/__tests__/route.test.ts": 1,
"app/api/fhir/metadata/route.ts": 1,
"app/api/fhir/Patient/$everything/route.ts": 1,
"app/api/fitbit/callback/route.ts": 1,
"app/api/fitbit/connect/route.ts": 1,
"app/api/fitbit/credentials/route.ts": 1,
@@ -289,6 +289,11 @@
"app/api/insights/breathing-screening/route.ts": 2,
"app/api/insights/cards/__tests__/route.test.ts": 2,
"app/api/insights/cards/route.ts": 8,
"app/api/insights/chat/[id]/attachments/[documentId]/route.ts": 1,
"app/api/insights/chat/[id]/attachments/__tests__/route.test.ts": 1,
"app/api/insights/chat/[id]/attachments/route.ts": 1,
"app/api/insights/chat/[id]/messages/[messageId]/trail/__tests__/route.test.ts": 1,
"app/api/insights/chat/[id]/route.ts": 1,
"app/api/insights/chat/__tests__/dialog-harness.ts": 1,
"app/api/insights/chat/__tests__/golden-transcripts.test.ts": 1,
"app/api/insights/chat/__tests__/route-activity.test.ts": 1,
@@ -300,19 +305,14 @@
"app/api/insights/chat/__tests__/route-snapshot-once.test.ts": 2,
"app/api/insights/chat/__tests__/route-tool-mode.test.ts": 4,
"app/api/insights/chat/__tests__/route-workout-scope.test.ts": 1,
"app/api/insights/chat/[id]/attachments/__tests__/route.test.ts": 1,
"app/api/insights/chat/[id]/attachments/[documentId]/route.ts": 1,
"app/api/insights/chat/[id]/attachments/route.ts": 1,
"app/api/insights/chat/[id]/messages/[messageId]/trail/__tests__/route.test.ts": 1,
"app/api/insights/chat/[id]/route.ts": 1,
"app/api/insights/chat/fenced/__tests__/route.test.ts": 1,
"app/api/insights/chat/fenced/route.ts": 1,
"app/api/insights/chat/messages/[id]/feedback/route.ts": 3,
"app/api/insights/chat/route.ts": 9,
"app/api/insights/coach-read/route.ts": 2,
"app/api/insights/coach/facts/[id]/route.ts": 2,
"app/api/insights/coach/facts/__tests__/route-memory.test.ts": 1,
"app/api/insights/coach/facts/__tests__/route.test.ts": 1,
"app/api/insights/coach/facts/[id]/route.ts": 2,
"app/api/insights/coach/facts/route.ts": 1,
"app/api/insights/coach/nudge-status/route.ts": 1,
"app/api/insights/coach/seen/route.ts": 1,
@@ -323,9 +323,9 @@
"app/api/insights/derived/batch/__tests__/route.test.ts": 3,
"app/api/insights/derived/batch/route.ts": 6,
"app/api/insights/derived/route.ts": 5,
"app/api/insights/ecg/__tests__/route.test.ts": 1,
"app/api/insights/ecg/[id]/__tests__/route.test.ts": 1,
"app/api/insights/ecg/[id]/route.ts": 2,
"app/api/insights/ecg/__tests__/route.test.ts": 1,
"app/api/insights/ecg/route.ts": 1,
"app/api/insights/feedback/route.ts": 2,
"app/api/insights/generate/__tests__/route.test.ts": 11,
@@ -352,7 +352,7 @@
"app/api/insights/rhythm-events/route.ts": 2,
"app/api/insights/settings/route.ts": 1,
"app/api/insights/targets/__tests__/route.test.ts": 1,
"app/api/insights/targets/route.ts": 2,
"app/api/insights/targets/route.ts": 1,
"app/api/insights/weight-status/route.ts": 2,
"app/api/integrations/healthkit/__tests__/route.test.ts": 4,
"app/api/integrations/healthkit/route.ts": 3,
@@ -364,8 +364,8 @@
"app/api/internal/deploy-webhook/route.ts": 1,
"app/api/internal/web-vitals/__tests__/route.test.ts": 1,
"app/api/internal/web-vitals/route.ts": 1,
"app/api/labs/__tests__/error-contract.test.ts": 1,
"app/api/labs/[id]/route.ts": 7,
"app/api/labs/__tests__/error-contract.test.ts": 1,
"app/api/labs/ocr/capability/route.ts": 1,
"app/api/labs/ocr/commit/__tests__/route.test.ts": 2,
"app/api/labs/ocr/commit/route.ts": 4,
@@ -385,13 +385,13 @@
"app/api/measurement-reminders/[id]/skip/route.ts": 1,
"app/api/measurement-reminders/[id]/snooze/route.ts": 1,
"app/api/measurement-reminders/route.ts": 3,
"app/api/measurements/[id]/__tests__/route.test.ts": 2,
"app/api/measurements/[id]/route.ts": 16,
"app/api/measurements/__tests__/arrival-wiring.test.ts": 1,
"app/api/measurements/__tests__/group-by-day.test.ts": 8,
"app/api/measurements/__tests__/put-duplicate-timestamp.test.ts": 1,
"app/api/measurements/__tests__/range-aggregation-route.test.ts": 3,
"app/api/measurements/__tests__/zod-multi-issue.test.ts": 4,
"app/api/measurements/[id]/__tests__/route.test.ts": 2,
"app/api/measurements/[id]/route.ts": 16,
"app/api/measurements/batch/__tests__/aggregated-hr-wire.test.ts": 1,
"app/api/measurements/batch/__tests__/duplicate-prefilter.test.ts": 2,
"app/api/measurements/batch/__tests__/hr-bucket-observability.test.ts": 1,
@@ -408,7 +408,6 @@
"app/api/measurements/series-batch/route.ts": 1,
"app/api/measurements/series/__tests__/route.test.ts": 5,
"app/api/measurements/series/route.ts": 6,
"app/api/medications/__tests__/route.test.ts": 10,
"app/api/medications/[id]/__tests__/route.test.ts": 8,
"app/api/medications/[id]/api-endpoint/route.ts": 2,
"app/api/medications/[id]/cadence/route.ts": 10,
@@ -420,10 +419,10 @@
"app/api/medications/[id]/dose-history/route.ts": 14,
"app/api/medications/[id]/glp1/__tests__/route.test.ts": 3,
"app/api/medications/[id]/glp1/route.ts": 8,
"app/api/medications/[id]/intake/__tests__/route.test.ts": 11,
"app/api/medications/[id]/intake/__tests__/source-provenance.test.ts": 1,
"app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts": 9,
"app/api/medications/[id]/intake/[eventId]/route.ts": 4,
"app/api/medications/[id]/intake/__tests__/route.test.ts": 11,
"app/api/medications/[id]/intake/__tests__/source-provenance.test.ts": 1,
"app/api/medications/[id]/intake/bulk-delete/route.ts": 7,
"app/api/medications/[id]/intake/import/__tests__/route.test.ts": 2,
"app/api/medications/[id]/intake/import/route.ts": 6,
@@ -431,11 +430,12 @@
"app/api/medications/[id]/inventory/[itemId]/route.ts": 8,
"app/api/medications/[id]/inventory/route.ts": 4,
"app/api/medications/[id]/phase-config/route.ts": 4,
"app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts": 1,
"app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts": 9,
"app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts": 1,
"app/api/medications/[id]/schedule-revisions/route.ts": 3,
"app/api/medications/[id]/side-effects/[logId]/route.ts": 2,
"app/api/medications/[id]/side-effects/route.ts": 5,
"app/api/medications/__tests__/route.test.ts": 10,
"app/api/medications/categories/[key]/route.ts": 1,
"app/api/medications/categories/route.ts": 1,
"app/api/medications/compliance/__tests__/route.test.ts": 2,
@@ -452,9 +452,9 @@
"app/api/medications/route.ts": 2,
"app/api/mental-health/assessments/__tests__/route.test.ts": 2,
"app/api/mental-health/assessments/route.ts": 6,
"app/api/mood-entries/__tests__/route.test.ts": 7,
"app/api/mood-entries/[id]/__tests__/route.test.ts": 4,
"app/api/mood-entries/[id]/route.ts": 22,
"app/api/mood-entries/__tests__/route.test.ts": 7,
"app/api/mood-entries/bulk-delete/__tests__/route.test.ts": 1,
"app/api/mood-entries/bulk-delete/route.ts": 4,
"app/api/mood-entries/bulk/__tests__/route.test.ts": 5,
@@ -544,12 +544,12 @@
"app/api/withings/connect/__tests__/route.test.ts": 1,
"app/api/withings/connect/route.ts": 3,
"app/api/withings/status/route.ts": 1,
"app/api/withings/webhook/__tests__/route.test.ts": 1,
"app/api/withings/webhook/[token]/__tests__/route.test.ts": 1,
"app/api/withings/webhook/__tests__/route.test.ts": 1,
"app/api/withings/webhook/route.ts": 2,
"app/api/workouts/[id]/route.ts": 2,
"app/api/workouts/__tests__/batch-create.test.ts": 3,
"app/api/workouts/__tests__/canonical-dedup.test.ts": 3,
"app/api/workouts/[id]/route.ts": 2,
"app/api/workouts/batch/route.ts": 3,
"app/api/workouts/route.ts": 2,
"app/auth/claim/page.tsx": 1,
@@ -651,19 +651,19 @@
"app/loading.tsx": 2,
"app/mcp/__tests__/route.test.ts": 5,
"app/measurements/page.tsx": 5,
"app/medications/[id]/history/page.tsx": 1,
"app/medications/[id]/page.tsx": 2,
"app/medications/__tests__/category-filter.test.tsx": 2,
"app/medications/__tests__/list-view-toggle.test.tsx": 5,
"app/medications/__tests__/page-prefetch.test.tsx": 1,
"app/medications/__tests__/take-all-due-affordance.test.tsx": 1,
"app/medications/[id]/history/page.tsx": 1,
"app/medications/[id]/page.tsx": 2,
"app/medications/new/page.tsx": 1,
"app/medications/page-client.tsx": 50,
"app/mental-wellbeing/page.tsx": 1,
"app/mood/page-client.tsx": 2,
"app/not-found.tsx": 2,
"app/onboarding/__tests__/page.test.tsx": 1,
"app/onboarding/[step]/page.tsx": 1,
"app/onboarding/__tests__/page.test.tsx": 1,
"app/onboarding/handover/page.tsx": 1,
"app/onboarding/page.tsx": 2,
"app/privacy/__tests__/page.test.tsx": 1,
@@ -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"],
},
@@ -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.
*/
+3 -8
View File
@@ -1557,7 +1557,7 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
},
"app/api/insights/targets/route.ts": {
domain: "record",
why: "The record's resolved targets. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.",
why: "The record's resolved targets: the bands and reference ranges the dashboard snapshot already paints on the whole-record grant, read the same way, with no provider on the path. GET only; nothing is written here.",
},
"app/api/insights/ecg/route.ts": {
domain: "record",
@@ -2108,11 +2108,6 @@ const DELEGABLE_MANAGE_ROUTES: Record<string, ManageEntry> = {
conditions: [],
why: "A score's daily course, read from the same rows the score pages and the day view read. Deterministic, no provider, nothing enqueued.",
},
"app/api/insights/targets/route.ts": {
domain: "record",
conditions: [],
why: "The record's resolved targets.",
},
"app/api/insights/ecg/route.ts": {
domain: "record",
conditions: [],
@@ -2315,9 +2310,9 @@ const ACTOR_ROUTES: Record<string, string> = {
* 276 -> 278.
*
* The Insights presence read joins the record list and the manage literal:
* the tab strip's gate over the overview, counts and flags only. 278 -> 280.
* the tab strip's gate over the overview, counts and flags only. 277 -> 279.
*/
const FROZEN_ENTRY_COUNT = 280;
const FROZEN_ENTRY_COUNT = 279;
/**
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
+7 -1
View File
@@ -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") {
@@ -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();
@@ -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;
+4 -1
View File
@@ -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({
@@ -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();
});
});
+3 -1
View File
@@ -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",
});
}
}
@@ -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();
});
+3 -1
View File
@@ -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`.
@@ -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([
{
+19 -9
View File
@@ -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,
+4 -3
View File
@@ -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
@@ -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.
@@ -81,9 +81,17 @@ async function postBulkDelete(request: NextRequest): Promise<Response> {
// 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");
@@ -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<ReturnType<typeof getSession>>);
// 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" }));
+8 -4
View File
@@ -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) {
@@ -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<string, unknown>;
}) =>
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,
@@ -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(
+51 -31
View File
@@ -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<typeof aiProviderPatchSchema>,
"localReasoningEffort" | "compatReasoningEffort"
>,
pickedNow: unknown,
): Promise<unknown> {
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) {
+22 -1
View File
@@ -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(<InsightsBmiPage />);
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();
});
});
+11 -5
View File
@@ -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
@@ -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", () => {
+6 -4
View File
@@ -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 },
@@ -76,6 +76,17 @@ function metricFields(existing: CustomMetricDto | undefined) {
};
}
/**
* The sentence for a refused save. A name clash is read by its code: a 409
* can also be the first save still running under the same key.
*/
function saveRefusal(err: unknown, t: (key: string) => string): string {
return err instanceof ApiError &&
err.meta?.errorCode === "customMetric.name_taken"
? t("customMetrics.form.nameTaken")
: localizedApiError(err, t, "customMetrics.form.saveError");
}
/**
* v1.25.5 — define or edit a custom metric.
*
@@ -150,11 +161,7 @@ export function CustomMetricForm({
);
onSuccess?.(saved);
} catch (err) {
setError(
err instanceof ApiError && err.status === 409
? t("customMetrics.form.nameTaken")
: localizedApiError(err, t, "customMetrics.form.saveError"),
);
setError(saveRefusal(err, t));
} finally {
setSubmitting(false);
}
@@ -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.
@@ -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(
<I18nProvider initialLocale="en">
<DocumentPickerCapNotice cut count={50} searching />
</I18nProvider>,
);
expect(html).toContain("Narrow the search");
expect(html).not.toContain("Search to find");
});
});
@@ -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({
})}
</ul>
)}
{cut && !list.isError ? (
<p
data-slot="document-picker-capped"
className="text-muted-foreground text-xs"
>
{t("documents.picker.capped", {
count: String(documents.length),
})}
</p>
) : null}
<DocumentPickerCapNotice
cut={cut && !list.isError}
count={documents.length}
searching={query !== ""}
/>
</div>
</ResponsiveSheet>
);
}
/**
* 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 (
<ListCapNotice
cap={{ truncated: cut, limit: count }}
message={t(
searching
? "documents.picker.cappedSearching"
: "documents.picker.capped",
{ count: String(count) },
)}
/>
);
}
@@ -7,9 +7,9 @@ import type { CorrelationResult } from "@/lib/insights/correlations";
* v1.4.20 — `<CorrelationCard>` 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).
*/
@@ -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);
@@ -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({
})}
</ul>
)}
{cut && !list.isError ? (
<p
data-slot="coach-attachment-picker-capped"
className="text-muted-foreground text-xs"
>
{t("documents.picker.capped", {
count: String(list.data?.documents.length ?? 0),
})}
</p>
) : null}
<DocumentPickerCapNotice
cut={cut && !list.isError}
count={list.data?.documents.length ?? 0}
searching={query !== ""}
/>
</div>
</ResponsiveSheet>
);
@@ -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<string, unknown>) =>
`${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"}');
});
});
@@ -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(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
expect(
checkboxes(
wrap(
<MeasurementCardList measurements={measurements} rows={listRows} />,
),
),
).toBe(1);
expect(
checkboxes(
wrap(
<MeasurementTable
measurements={measurements}
rows={listRows}
filters={
{
sortBy: "measuredAt",
sortDir: "desc",
toggleSort: () => {},
} as unknown as Parameters<typeof MeasurementTable>[0]["filters"]
}
list={
{
selectAll: "none",
pageIds: [],
onToggleSelectAll: () => {},
} as unknown as Parameters<typeof MeasurementTable>[0]["list"]
}
/>,
),
),
).toBe(1);
});
});
@@ -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({
>
<div className="flex items-center justify-between">
<div className="flex min-w-0 flex-1 items-center gap-2">
{!isGrouped && canManage && (
{!isGrouped && !isServerComputed(m) && canManage && (
// The checkbox owns its 44 px hit area through an `after`
// hit-slop inside a plain layout `<div>`: a wrapper `<button>`
// would nest a button in a button, invalid markup that failed
@@ -23,6 +23,7 @@ import { MEASUREMENT_TYPE_COLORS as TYPE_COLORS } from "../measurement-list-meta
import { DayDrillDown, SleepNightCaption } from "./day-drill-down";
import {
MeasurementRowActions,
isServerComputed,
rowState,
typeLabel,
type MeasurementRowContext,
@@ -154,8 +155,9 @@ function MeasurementTableRow({
data-display-unit={isGrouped ? m.unit : rowDisplay(m).unit}
>
<TableCell className="pl-4">
{/* A grouped row is not individually deletable: no checkbox. */}
{!isGrouped && canManage && (
{/* A grouped row is not individually deletable, and a computed score
is the server's to rewrite: no checkbox for either. */}
{!isGrouped && !isServerComputed(m) && canManage && (
<Checkbox
checked={isSelected}
onCheckedChange={() => onToggleRow(m.id)}
@@ -103,20 +103,20 @@ export function editValueRange(
type Translate = ReturnType<typeof useTranslations>["t"];
/** The sentence for a value outside its band, in the unit the input shows. */
function outOfRangeMessage(
export function outOfRangeMessage(
display: Pick<MeasurementRowDisplay, "rowDisplay">,
measurement: Measurement,
t: Translate,
formatNumber: (value: number) => string,
): string {
const range = editValueRange(display, measurement);
return range
? t("measurements.valueOutOfRange", {
min: formatNumber(range.min),
max: formatNumber(range.max),
unit: range.unit,
})
: t("measurements.invalidValue");
if (!range) return t("measurements.invalidValue");
const bounds = { min: formatNumber(range.min), max: formatNumber(range.max) };
// A score, rating, count or ratio has no unit label, which left a space
// before the full stop.
return range.unit
? t("measurements.valueOutOfRange", { ...bounds, unit: range.unit })
: t("measurements.valueOutOfRangeNoUnit", bounds);
}
/**
@@ -22,6 +22,7 @@ import {
} from "@/lib/query-keys";
import { PAGE_SIZE, isGroupedRow, type Measurement } from "./list-model";
import { isServerComputed } from "./row-actions";
import type { MeasurementListFilters } from "./use-measurement-list-filters";
/**
@@ -178,9 +179,10 @@ export function useMeasurementListData({
});
// Synthetic day and night rows collapse many raw rows behind a `dayKey`
// and are not individually deletable, so they are not selectable.
// and are not individually deletable, and a computed score is rewritten
// by the server, so neither is selectable.
const pageIds = (data?.measurements ?? [])
.filter((m) => !isGroupedRow(m))
.filter((m) => !isGroupedRow(m) && !isServerComputed(m))
.map((m) => m.id);
const selectAll = selectAllState(selectedIds, pageIds);
const selectedOnPage = selectedCountOnPage(selectedIds, pageIds);
@@ -117,11 +117,11 @@ describe("<DoneScreen> AI line", () => {
);
expect(line).not.toBeNull();
expect(line![1]).toContain("AI is optional");
expect(line![1]).toContain('href="/settings/ai"');
// No sample briefing, no provider ladder, no one-tap offer.
expect(html).not.toContain("Example");
expect(html).not.toContain("Three ways to turn it on");
expect(html).not.toMatch(/<button[^>]*>[^<]*Turn on/);
// Body copy, not meta: the line is read, so it is not muted.
expect(line![0]).not.toContain("text-muted-foreground");
// One way on, the link, and no one-tap offer beside it.
expect(html.split('href="/settings/ai"')).toHaveLength(2);
expect(line![1]).not.toContain("<button");
});
it("is left out when AI cannot be set up here", () => {
+10 -2
View File
@@ -9,6 +9,8 @@ import {
type BaselineFieldErrors,
} from "@/components/onboarding/baseline-fields";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { dateOfBirthKey } from "@/lib/tz/date-only";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import {
resolveHeightUnitAdapter,
type HeightDraft,
@@ -59,6 +61,8 @@ interface BaselineInitialValues {
heightCm: number | null;
dateOfBirth: string | null;
gender: string | null;
/** The owner's zone, which a date of birth from the iOS app is read in. */
timezone?: string;
}
interface BaselineFormState {
@@ -133,8 +137,12 @@ export function BaselineForm({
const [form, setForm] = useState<BaselineFormState>(() => ({
displayName: "",
height: heightAdapter.toDraft(initial.heightCm),
// The payload's ISO instant, as the date field's YYYY-MM-DD.
dateOfBirth: initial.dateOfBirth ? initial.dateOfBirth.slice(0, 10) : "",
dateOfBirth: initial.dateOfBirth
? dateOfBirthKey(
new Date(initial.dateOfBirth),
initial.timezone ?? DEFAULT_TIMEZONE,
)
: "",
gender: initial.gender ?? "",
}));
const [saving, setSaving] = useState(false);
+3 -5
View File
@@ -35,6 +35,8 @@ import {
questionsToPassBeforeConfirm,
} from "@/lib/onboarding/wizard-steps";
const NO_BASELINE = { heightCm: null, dateOfBirth: null, gender: null };
/**
* v1.39 — the confirm screen.
*
@@ -334,11 +336,7 @@ export function ConfirmScreen({
) : (
<BaselineForm
backHref={back ? screenHref(back) : undefined}
initial={{
heightCm: user?.heightCm ?? null,
dateOfBirth: user?.dateOfBirth ?? null,
gender: user?.gender ?? null,
}}
initial={user ?? NO_BASELINE}
onConfirmed={finish}
confirming={finishing}
demoMode={demoMode}
+1 -4
View File
@@ -123,10 +123,7 @@ export function DoneScreen({ state }: { state: OnboardingStateDto }) {
</div>
{showAiLine ? (
<p
data-slot="onboarding-ai-line"
className="text-muted-foreground max-w-md text-sm"
>
<p data-slot="onboarding-ai-line" className="max-w-md text-sm">
{t("onboarding.done.aiOptional")}{" "}
<InlineAction asChild size="sm" className="underline">
<Link href="/settings/ai">{t("onboarding.done.aiSetupCta")}</Link>
@@ -4,6 +4,7 @@
* The delete now confirms with an Undo that calls the restore.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MutationObserver, QueryClient } from "@tanstack/react-query";
const toastSuccess = vi.hoisted(() => vi.fn());
const errorToast = vi.hoisted(() => vi.fn());
@@ -16,13 +17,9 @@ type Mutations = Parameters<typeof deleteWithUndo>[0];
const t = (key: string) => key;
function mutations(restoreResult: Promise<unknown>) {
const remove = {
mutate: vi.fn((_id: string, options: { onSuccess?: () => void }) =>
options.onSuccess?.(),
),
};
const restore = { mutateAsync: vi.fn(() => restoreResult) };
function mutations(restoreResult: () => Promise<unknown>) {
const remove = { mutateAsync: vi.fn(async () => ({ deleted: true })) };
const restore = { mutateAsync: vi.fn(restoreResult) };
return { remove, restore, all: { remove, restore } as unknown as Mutations };
}
@@ -33,11 +30,12 @@ beforeEach(() => {
describe("deleteWithUndo", () => {
it("confirms the delete with an Undo that restores the entry", async () => {
const m = mutations(Promise.resolve({ id: "p1" }));
const m = mutations(async () => ({ id: "p1" }));
deleteWithUndo(m.all, "p1", t);
expect(m.remove.mutate).toHaveBeenCalledWith("p1", expect.anything());
expect(m.remove.mutateAsync).toHaveBeenCalledWith("p1");
await vi.waitFor(() => expect(toastSuccess).toHaveBeenCalled());
const [message, options] = toastSuccess.mock.calls[0]!;
expect(message).toBe("practitioners.deleted");
expect(options.action.label).toBe("common.undo");
@@ -48,9 +46,12 @@ describe("deleteWithUndo", () => {
});
it("says so when the restore fails", async () => {
const m = mutations(Promise.reject(new Error("offline")));
const m = mutations(async () => {
throw new Error("offline");
});
deleteWithUndo(m.all, "p1", t);
await vi.waitFor(() => expect(toastSuccess).toHaveBeenCalled());
toastSuccess.mock.calls[0]![1].action.onClick();
await vi.waitFor(() =>
@@ -60,4 +61,26 @@ describe("deleteWithUndo", () => {
),
);
});
it("offers an Undo for each of two quick deletes", async () => {
// One mutation observer for the list, as `useMutation` keeps it: the
// callbacks of an earlier `mutate` are dropped once a later one starts.
const observer = new MutationObserver(new QueryClient(), {
mutationFn: async (id: string) => ({ id }),
});
const remove = {
mutate: (id: string, options?: object) =>
void observer.mutate(id, options).catch(() => undefined),
mutateAsync: (id: string) => observer.mutate(id),
};
const restore = { mutateAsync: vi.fn(async () => ({})) };
const all = { remove, restore } as unknown as Mutations;
deleteWithUndo(all, "p1", t);
deleteWithUndo(all, "p2", t);
await vi.waitFor(() => expect(toastSuccess).toHaveBeenCalledTimes(2));
for (const call of toastSuccess.mock.calls) call[1].action.onClick();
expect(restore.mutateAsync.mock.calls).toEqual([["p1"], ["p2"]]);
});
});
@@ -46,15 +46,17 @@ import { PractitionerSheet } from "./practitioner-sheet";
/**
* Delete an entry, with an Undo: the delete is soft, and the restore brings
* the entry back.
* the entry back. Each delete answers on its own promise: the callbacks
* `mutate` takes fire for the latest call only, so of two quick deletes only
* the second offered an Undo.
*/
export function deleteWithUndo(
{ remove, restore }: ReturnType<typeof usePractitionerMutations>,
id: string,
t: (key: string) => string,
) {
remove.mutate(id, {
onSuccess: () =>
remove.mutateAsync(id).then(
() =>
toast.success(t("practitioners.deleted"), {
action: {
label: t("common.undo"),
@@ -66,9 +68,9 @@ export function deleteWithUndo(
),
},
}),
onError: (failure) =>
(failure) =>
showErrorToast(t("practitioners.deleteFailed"), { cause: failure }),
});
);
}
export function PractitionerList({ enabled = true }: { enabled?: boolean }) {
@@ -1,89 +0,0 @@
/**
* Settings lists the Coach entry only while the Coach can answer, like the
* sidebar and the bottom bar, and Settings → AI then keeps the stored Coach
* memory in reach.
*
* Both used to read the raw module map, which answers the operator's switch
* and Hide Coach but not a missing provider or missing consent: the settings
* nav kept a Coach entry the sidebar had already dropped. The two now read
* the same map (`useNavModules`), so the memory's own entry and the fallback
* under Settings → AI cannot both go.
*
* The shell's module filter runs after hydration only, so `useMounted` is
* mocked to the hydrated answer.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
const h = vi.hoisted(() => ({ coachAvailable: true }));
vi.mock("next/navigation", () => ({
usePathname: () => "/settings/ai",
useRouter: () => ({ replace: () => {}, push: () => {} }),
}));
vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true }));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1", role: "USER", modules: { coach: true } },
isAuthenticated: true,
isLoading: false,
refetch: vi.fn(),
}),
}));
vi.mock("@/hooks/use-ai-capability", () => ({
useAiCapability: () => ({
available: h.coachAvailable,
reason: h.coachAvailable ? null : "no_provider",
onDeviceAllowed: true,
}),
}));
// Settings → AI's cards have their own suites; only the memory slot is
// under test here.
vi.mock("@/components/ai/ai-consent-request", () => ({
AiConsentRequest: () => null,
}));
vi.mock("@/components/settings/ai/ai-insights-card", () => ({
AiInsightsCard: () => null,
}));
vi.mock("@/components/settings/ai/operator-off-notice", () => ({
OperatorOffNotice: () => null,
}));
vi.mock("@/components/insights/ai-setup-hint", () => ({
AiSetupHint: () => null,
}));
vi.mock("@/components/settings/coach-memory-section", () => ({
StoredCoachMemory: () => <div data-slot="stored-coach-memory" />,
}));
import { I18nProvider } from "@/lib/i18n/context";
import { AiSection } from "../ai-section";
import { SettingsShell } from "../settings-shell";
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
describe("the Coach entry in Settings", () => {
beforeEach(() => {
h.coachAvailable = true;
});
it("is listed while the Coach can answer, and the memory lives there", () => {
expect(render(<SettingsShell active="ai">body</SettingsShell>)).toContain(
'href="/settings/coach"',
);
expect(render(<AiSection />)).not.toContain(
'data-slot="stored-coach-memory"',
);
});
it("goes when the Coach cannot answer, and Settings → AI keeps the memory", () => {
h.coachAvailable = false;
expect(
render(<SettingsShell active="ai">body</SettingsShell>),
).not.toContain('href="/settings/coach"');
expect(render(<AiSection />)).toContain('data-slot="stored-coach-memory"');
});
});
@@ -52,7 +52,7 @@ import {
} from "./account-section-utils";
import { AvatarSection } from "./avatar-section";
import { SetupCard } from "./setup-card";
import { dateOnlyKey } from "@/lib/tz/date-only";
import { dateOfBirthKey } from "@/lib/tz/date-only";
import { translateCodeOrNull } from "@/lib/i18n/translate-code";
export { resolveInitialTimezone } from "./account-section-utils";
@@ -149,7 +149,7 @@ export function AccountSection() {
displayName: user.displayName ?? "",
height: heightAdapter.toDraft(user.heightCm),
dateOfBirth: user.dateOfBirth
? dateOnlyKey(new Date(user.dateOfBirth))
? dateOfBirthKey(new Date(user.dateOfBirth), user.timezone)
: "",
gender: user.gender ?? "",
fullName: user.fullName ?? "",
+6 -6
View File
@@ -25,12 +25,10 @@ import { StoredCoachMemory } from "@/components/settings/coach-memory-section";
import { AiSetupHint } from "@/components/insights/ai-setup-hint";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { useNavModules } from "@/hooks/use-nav-modules";
import { isSurfaceVisible } from "@/lib/modules/surface";
export function AiSection() {
const { isAuthenticated } = useAuth();
const navModules = useNavModules();
const { isAuthenticated, user } = useAuth();
// v1.16.6 — the auth query can resolve before this boundary
// hydrates; every `disabled={!isAuthenticated}` binding in the child
// cards would then disagree with the SSR HTML (React #418). Gate the
@@ -48,9 +46,11 @@ export function AiSection() {
// nothing below reads as broken), and the one calm setup hint while no
// provider is configured, or the consent question while the operator's
// provider waits on it. Below it: what the Coach stored, while Settings →
// Coach is not listed (the settings nav reads the same map), so it can
// always be read and deleted.
const coachSettingsReachable = isSurfaceVisible("settings:coach", navModules);
// Coach is not reachable, so it can always be read and deleted.
const coachSettingsReachable = isSurfaceVisible(
"settings:coach",
user?.modules,
);
return (
<div className="space-y-6">
<OperatorOffNotice />
@@ -89,7 +89,7 @@ export function CodexProviderForm({
intervalSeconds: data.intervalSeconds,
});
} catch (err) {
setMsg(localizedApiError(err, t, "settings.savingError"));
setMsg(localizedApiError(err, t, "settings.codexConnectionFailed"));
setMsgType("error");
setDevicePolling(false);
}
@@ -117,7 +117,7 @@ export function CodexProviderForm({
if (!cancelled) setTimeout(tick, intervalMs);
} catch (err) {
if (cancelled) return;
setMsg(localizedApiError(err, t, "settings.savingError"));
setMsg(localizedApiError(err, t, "settings.codexConnectionFailed"));
setMsgType("error");
setDeviceCode(null);
setDevicePolling(false);
+2 -2
View File
@@ -127,8 +127,8 @@ export const LOCAL_MODEL_PRESETS = [
export const CUSTOM_MODEL_SENTINEL = "__custom__";
/**
* Map UI provider tag → the `aiProvider` enum understood by the
* legacy single-result `resolveProvider()` resolver. `codex` and
* Map UI provider tag → the stored `aiProvider` enum, the provider picked
* under Settings, AI. `codex` and
* `admin-openai` aren't user-level provider columns (they're handled
* by Codex OAuth + admin-key fallback), so they map to null and the
* legacy column is left blank.
@@ -31,7 +31,10 @@ import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { waitForMedicationIntakeImport } from "@/lib/medications/intake-import-poll";
import {
MedicationIntakeImportTimeout,
waitForMedicationIntakeImport,
} from "@/lib/medications/intake-import-poll";
import { invalidateMedicationReads } from "@/lib/query-keys";
import { DoseHistoryColumnRulings } from "./dose-history-columns";
@@ -93,6 +96,17 @@ function looksLikeJson(text: string): boolean {
return head === "[" || head === "{";
}
/**
* The sentence for an import that did not report a result. A failed run or
* a transport failure wrote nothing; a job still running when the watch gave
* up may still write, so it must not read as nothing written.
*/
function importFailureKey(err: unknown): string {
return err instanceof MedicationIntakeImportTimeout
? "settings.sections.export.import.doseHistory.stillRunning"
: "settings.sections.export.import.doseHistory.failed";
}
export function DoseHistoryImportCard() {
const { t } = useTranslations();
const fatalMessageFor = useFatalMessage();
@@ -191,12 +205,11 @@ export function DoseHistoryImportCard() {
skippedDetailsOmitted: result.skippedDetailsOmitted,
});
void invalidateMedicationReads(queryClient);
} catch {
} catch (err) {
// A failed job's reason is the worker's English and adds nothing;
// anything else is a transport failure. Either way the run did not
// write, and the card says so rather than leaving the last verdict on
// screen.
setError(t("settings.sections.export.import.doseHistory.failed"));
// anything else is a transport failure. The card says what that means
// rather than leaving the last verdict on screen.
setError(t(importFailureKey(err)));
setVerdict(null);
} finally {
setBusy(false);
+3 -4
View File
@@ -47,7 +47,6 @@ import { cn } from "@/lib/utils";
import { PageHeader } from "@/components/ui/page-header";
import { SUB_SHELL_GRID_FLOOR } from "@/components/layout/shell-metrics";
import { useAuth } from "@/hooks/use-auth";
import { useNavModules } from "@/hooks/use-nav-modules";
import { useMounted } from "@/hooks/use-mounted";
import { useTranslations } from "@/lib/i18n/context";
import { isSurfaceVisible } from "@/lib/modules/surface";
@@ -367,8 +366,8 @@ export function SettingsShell({
const activeSlug = deriveActiveSlug(pathname, active);
// v1.18.0 — per-submodule entries are listed only when their module
// is enabled, from `useNavModules()` as in the sidebar (Coach follows its
// AI capability; Settings → AI then lists the stored memory). Fail OPEN
// is enabled. Read from the resolved `useAuth().user.modules` map (the
// same map the Module hub, nav, and Insights pills gate off). Fail OPEN
// (`!== false`): a missing key, or a not-yet-resolved `/me` payload,
// reads as enabled so an entry never silently disappears. A section's
// owner comes from the surface map (`settings:<slug>`); sections with no
@@ -391,7 +390,7 @@ export function SettingsShell({
// effect (which the lint rule flags as a cascading render), and no risk of
// the two passes disagreeing.
const hydrated = useMounted();
const modules = useNavModules();
const modules = user?.modules;
const activeRecord = user?.accountAccess?.active ?? null;
const allVisibleSections = SETTINGS_SECTIONS.filter(
(section) =>
+5 -2
View File
@@ -8,14 +8,17 @@ import { cn } from "@/lib/utils";
* The line under a list the server cut at its ceiling. Renders nothing for a
* complete list, so a surface passes the cap it read (`apiGetList`) and
* never decides on its own whether to show it. Meta tier: one muted sentence
* at the `text-xs` floor.
* at the `text-xs` floor. `message` replaces the generic sentence where the
* surface can say how to reach the rest (a search).
*/
export function ListCapNotice({
cap,
className,
message,
}: {
cap: ListCap | null | undefined;
className?: string;
message?: string;
}) {
const { t } = useTranslations();
if (!cap?.truncated) return null;
@@ -24,7 +27,7 @@ export function ListCapNotice({
data-slot="list-cap-notice"
className={cn("text-muted-foreground text-xs", className)}
>
{t("common.listCapped", { count: String(cap.limit) })}
{message ?? t("common.listCapped", { count: String(cap.limit) })}
</p>
);
}
@@ -1,7 +1,11 @@
import { describe, it, expect, vi } from "vitest";
import { QueryClient, QueryObserver } from "@tanstack/react-query";
import { invalidateReminderReads } from "@/hooks/use-measurement-reminders";
import {
invalidateReminderReads,
putReminderRow,
type MeasurementReminder,
} from "@/hooks/use-measurement-reminders";
import { queryKeys } from "@/lib/query-keys";
/**
@@ -71,3 +75,23 @@ describe("invalidateReminderReads — the Vorsorge daily-rail freshness contract
client.clear();
});
});
describe("putReminderRow", () => {
it("shows the answered row at once, so it no longer reads as due", () => {
const client = new QueryClient();
const due = {
id: "r1",
nextDueAt: "2026-11-11T08:00:00.000Z",
} as MeasurementReminder;
const other = { id: "r2" } as MeasurementReminder;
client.setQueryData(queryKeys.measurementReminders(), [due, other]);
const done = { ...due, nextDueAt: "2027-11-11T08:00:00.000Z" };
putReminderRow(client, done);
expect(client.getQueryData(queryKeys.measurementReminders())).toEqual([
done,
other,
]);
});
});
+22 -3
View File
@@ -115,10 +115,29 @@ export function useMeasurementReminders(enabled = true) {
});
}
/**
* Write the row a satisfy, skip or snooze answered with into the cached list.
* The button re-enables as the call settles, and with only the refetch behind
* it the row still read as due, so a second tap marked it done twice.
*/
export function putReminderRow(
qc: QueryClient,
reminder: MeasurementReminder,
): void {
qc.setQueryData<MeasurementReminder[]>(
queryKeys.measurementReminders(),
(rows) => rows?.map((row) => (row.id === reminder.id ? reminder : row)),
);
}
export function useMeasurementReminderMutations() {
const qc = useQueryClient();
// Not returned: a mutation would wait for every refetch before it settles.
const invalidate = () => void invalidateReminderReads(qc);
const answered = (reminder: MeasurementReminder) => {
putReminderRow(qc, reminder);
invalidate();
};
const create = useMutation({
mutationKey: queryKeys.measurementReminderCreate(),
@@ -150,7 +169,7 @@ export function useMeasurementReminderMutations() {
mutationKey: queryKeys.measurementReminderSatisfy(),
mutationFn: (id: string) =>
apiPost<MeasurementReminder>(`${BASE}/${id}/satisfy`),
onSuccess: invalidate,
onSuccess: answered,
});
// v1.37.20 (#223) — honest skip: the interval restarts from the skip
@@ -161,7 +180,7 @@ export function useMeasurementReminderMutations() {
apiPost<{ skipped: boolean; reminder: MeasurementReminder }>(
`${BASE}/${id}/skip`,
),
onSuccess: invalidate,
onSuccess: ({ reminder }) => answered(reminder),
});
// v1.37.20 (#223) — snooze to a calendar day; the server resolves it to
@@ -172,7 +191,7 @@ export function useMeasurementReminderMutations() {
apiPost<{ reminder: MeasurementReminder }>(`${BASE}/${id}/snooze`, {
until,
}),
onSuccess: invalidate,
onSuccess: ({ reminder }) => answered(reminder),
});
return { create, update, remove, satisfy, skip, snooze };
@@ -77,12 +77,12 @@ describe("listTelegramDoseMedications", () => {
describe("findTelegramDoseMedication", () => {
it("resolves a current course", async () => {
db.findFirst.mockResolvedValue(CHRONIC);
db.findMany.mockResolvedValue([CHRONIC]);
const found = await findTelegramDoseMedication("user-1", null, {
id: "chronic",
});
expect(found?.id).toBe("chronic");
expect(db.findFirst).toHaveBeenCalledWith(
expect(db.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
id: "chronic",
@@ -95,7 +95,7 @@ describe("findTelegramDoseMedication", () => {
});
it("refuses an ended course", async () => {
db.findFirst.mockResolvedValue(ENDED_NOW);
db.findMany.mockResolvedValue([ENDED_NOW]);
expect(
await findTelegramDoseMedication("user-1", "Europe/Berlin", {
id: "ended-now",
@@ -103,11 +103,31 @@ describe("findTelegramDoseMedication", () => {
).toBeNull();
});
it("finds the current course beside an ended one of the same name", async () => {
db.findMany.mockResolvedValue([ENDED, CHRONIC]);
const found = await findTelegramDoseMedication("user-1", "Europe/Berlin", {
name: { equals: "ramipril", mode: "insensitive" },
});
expect(found?.id).toBe("chronic");
});
it("judges the course at the dose's own slot", async () => {
// The last evening dose, confirmed after midnight once the course ended.
db.findMany.mockResolvedValue([ENDED_NOW]);
const found = await findTelegramDoseMedication(
"user-1",
"Europe/Berlin",
{ id: "ended-now" },
new Date(Date.now() - 3 * 60 * 60 * 1000),
);
expect(found?.id).toBe("ended-now");
});
it("refuses everything with the Medications module off", async () => {
db.moduleOn.mockResolvedValue(false);
expect(
await findTelegramDoseMedication("user-1", null, { id: "chronic" }),
).toBeNull();
expect(db.findFirst).not.toHaveBeenCalled();
expect(db.findMany).not.toHaveBeenCalled();
});
});
+22 -1
View File
@@ -19,6 +19,7 @@ vi.mock("@/lib/crypto", () => ({
}));
import { prisma } from "@/lib/db";
import { chainWithReasoningEffort } from "../provider-chain";
import type { ProviderWorkAuthority } from "@/lib/sharing/provider-work-authority";
import {
@@ -238,16 +239,36 @@ describe("probeProviderChain", () => {
]);
expect(presence.managedBy).toBe("user");
// Once the person saved a chain without it, the chain is their word.
// A saved chain without it says nothing about the gateway, and neither
// does the switched-off entry the gateway form wrote for its reasoning
// effort.
for (const aiProviderChain of [
[{ providerType: "local", enabled: true, priority: 1 }],
chainWithReasoningEffort(null, "openai-compatible", "low"),
]) {
load(row({ ...gateway, aiProviderChain }));
expect(
(await probeProviderChain("u1", OWNER)).entries.map(
(e) => e.providerType,
),
).toEqual(["openai-compatible"]);
}
// Switched off in the chain, the gateway stays off.
load(
row({
...gateway,
aiProviderChain: [
{ providerType: "local", enabled: true, priority: 1 },
{ providerType: "openai-compatible", enabled: false, priority: 2 },
],
}),
);
expect((await probeProviderChain("u1", OWNER)).entries).toEqual([]);
expect(await resolveProviderAvailability("u1")).toEqual({
aiAvailable: false,
managedBy: null,
});
});
it("carries the person's in-browser OCR opt-in", async () => {
+15
View File
@@ -352,6 +352,21 @@ describe("resolveProviderForTest — empty override resolves via the chain", ()
expect(provider.type).not.toBe("none");
expect(provider).toBeInstanceOf(OpenAIClient);
});
it("answers no provider, which the route names, when the chain resolves empty", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
aiProvider: null,
aiModel: null,
aiBaseUrl: null,
aiAnthropicKeyEncrypted: null,
aiLocalKeyEncrypted: null,
aiOpenaiKeyEncrypted: null,
aiProviderChain: [{ providerType: "openai", priority: 1, enabled: true }],
} as unknown as Awaited<ReturnType<typeof prisma.user.findUnique>>);
const provider = await resolveProviderForTest("user-123", {});
expect(provider.type).toBe("none");
});
});
describe("resolveProviderAvailability", () => {
+62 -32
View File
@@ -17,6 +17,10 @@ vi.mock("@/lib/ai/codex-oauth", () => ({
}));
import { resolveProviderChain } from "../provider";
import {
chainWithReasoningEffort,
PROVIDER_CHAIN_DEFAULT,
} from "../provider-chain";
import { prisma } from "@/lib/db";
import { decryptCodexCreds } from "@/lib/ai/codex-oauth";
@@ -207,41 +211,67 @@ describe("resolveProviderChain", () => {
expect(await resolveProviderChain("user-1")).toEqual([]);
});
});
it("serves the picked gateway, under its own name, on a chain never saved", async () => {
const gatewayRow = {
aiProvider: "OPENAI_COMPATIBLE",
aiModel: null,
aiBaseUrl: null,
aiAnthropicKeyEncrypted: null,
aiLocalKeyEncrypted: null,
aiOpenaiKeyEncrypted: null,
aiCompatBaseUrl: "https://gateway.example/v1",
aiCompatKeyEncrypted: null,
aiCompatModel: "llama-3",
role: "USER",
};
userRows(
{ ...gatewayRow, aiProviderChain: null },
{
codexAccessTokenEncrypted: null,
codexRefreshTokenEncrypted: null,
codexTokenExpiresAt: null,
codexConnectionStatus: "disconnected",
},
describe("resolveProviderChain and the picked gateway", () => {
const gatewayRow = {
aiProvider: "OPENAI_COMPATIBLE",
aiModel: null,
aiBaseUrl: null,
aiAnthropicKeyEncrypted: null,
aiLocalKeyEncrypted: null,
aiOpenaiKeyEncrypted: null,
aiCompatBaseUrl: "https://gateway.example/v1",
aiCompatKeyEncrypted: null,
aiCompatModel: "llama-3",
role: "USER",
};
const DEFAULT_SAVED = PROVIDER_CHAIN_DEFAULT.map((entry) => ({ ...entry }));
/** Serve `chain` as the record's saved chain; Codex is not connected. */
async function resolveWith(chain: unknown) {
const read = async (args: { select?: Record<string, unknown> }) =>
args.select?.codexAccessTokenEncrypted
? {
codexAccessTokenEncrypted: null,
codexRefreshTokenEncrypted: null,
codexTokenExpiresAt: null,
codexConnectionStatus: "disconnected",
}
: { ...gatewayRow, aiProviderChain: chain };
vi.mocked(prisma.user.findUnique).mockImplementation(
read as unknown as typeof prisma.user.findUnique,
);
operator({
adminAiKeyEncrypted: null,
});
operator({ adminAiKeyEncrypted: null });
return (await resolveProviderChain("user-1")).map((c) => c.providerType);
}
const chain = await resolveProviderChain("user-1");
expect(chain.map((c) => c.providerType)).toEqual(["openai-compatible"]);
it.each([
["never saved", null],
["saved with the default content", DEFAULT_SAVED],
[
"reset to the default and saved",
JSON.parse(JSON.stringify(DEFAULT_SAVED)),
],
["reordered without the gateway", [...DEFAULT_SAVED].reverse()],
[
"holding a reasoning effort the gateway form wrote switched off",
chainWithReasoningEffort(null, "openai-compatible", "low"),
],
[
"holding a reasoning effort the gateway form writes now",
chainWithReasoningEffort(null, "openai-compatible", "low", true),
],
])("serves the gateway on a chain %s", async (_case, chain) => {
expect(await resolveWith(chain)).toEqual(["openai-compatible"]);
});
// A saved chain without the gateway is the person's word.
userRows({
...gatewayRow,
aiProviderChain: [{ providerType: "local", priority: 1, enabled: true }],
});
expect(await resolveProviderChain("user-1")).toEqual([]);
it("serves nothing once the person switched the gateway off in the chain", async () => {
expect(
await resolveWith([
...DEFAULT_SAVED,
{ providerType: "openai-compatible", priority: 6, enabled: false },
]),
).toEqual([]);
});
});
@@ -336,3 +336,17 @@ describe("getCoachSystemPrompt — native locales name the reader's unit", () =>
},
);
});
describe("the plan review rule", () => {
// A reviewed plan has no progress line, so the rule must not leave the
// model a result to make up.
it.each([
["en", "Without one you have no result: ask how it went"],
["de", "Ohne Fortschrittszeile hast du kein Ergebnis"],
] as const)(
"names no result without a progress line (%s)",
(locale, rule) => {
expect(getCoachSystemPrompt(locale)).toContain(rule);
},
);
});
+2 -2
View File
@@ -1419,8 +1419,8 @@ function buildCoachV122Addendum(locale: Locale): string {
parts.push(
isDe
? `EXPERIMENT-RÜCKBLICK — sprichst du darüber, wie ein vereinbarter Plan gelaufen ist, lies es aus seiner Fortschrittszeile in den eigenen Zahlen des Nutzers zurück, rein assoziativ ("sieht beibehaltenswert aus" / "hängt zusammen mit", NIEMALS "bewiesen" / "hat funktioniert" / "verursacht"). Ein Null-Ergebnis berichte ehrlich ("keine messbare Veränderung — deine Entscheidung"). Hat sich ein Vitalwert während des Experiments VERSCHLECHTERT, bejuble das nicht — sprich es klar an und verweise an die Ärztin/den Arzt bzw. den Akut-Pfad. Schlage NUR verhaltensbezogene Experimente vor (Schlafzeit, Schritte, Bildschirmzeit, Essenszeit); nie ein Medikamenten-/Dosis-/Präparate-Experiment — lehne das ab und verweise an die verschreibende Person.`
: `EXPERIMENT REVIEW — when you speak to how an agreed plan went, read it back from its progress line in the user's own numbers, association-only ("looks worth keeping" / "associated with", NEVER "proven" / "it worked" / "caused"). Report a null result honestly ("no measurable change — your call"). If a vital WORSENED during the experiment, do NOT cheerlead it — raise it plainly and route to the doctor / the acute-redflag path. Only ever propose BEHAVIORAL experiments (sleep timing, steps, screen time, meal timing); never a medication / dose / supplement trial — refuse those and defer to the prescriber.`,
? `EXPERIMENT-RÜCKBLICK — sprichst du darüber, wie ein vereinbarter Plan gelaufen ist, lies es NUR aus einer Fortschrittszeile zurück, die der Snapshot für diesen Plan trägt, in den eigenen Zahlen des Nutzers. Ohne Fortschrittszeile hast du kein Ergebnis: frag, wie es gelaufen ist, statt eines zu nennen. Bleib rein assoziativ ("sieht beibehaltenswert aus" / "hängt zusammen mit", NIEMALS "bewiesen" / "hat funktioniert" / "verursacht"). Ein Null-Ergebnis berichte ehrlich ("keine messbare Veränderung — deine Entscheidung"). Hat sich ein Vitalwert während des Experiments VERSCHLECHTERT, bejuble das nicht — sprich es klar an und verweise an die Ärztin/den Arzt bzw. den Akut-Pfad. Schlage NUR verhaltensbezogene Experimente vor (Schlafzeit, Schritte, Bildschirmzeit, Essenszeit); nie ein Medikamenten-/Dosis-/Präparate-Experiment — lehne das ab und verweise an die verschreibende Person.`
: `EXPERIMENT REVIEW — when you speak to how an agreed plan went, read it back ONLY from a progress line the snapshot carries for that plan, in the user's own numbers. Without one you have no result: ask how it went instead of naming one. Stay association-only ("looks worth keeping" / "associated with", NEVER "proven" / "it worked" / "caused"). Report a null result honestly ("no measurable change — your call"). If a vital WORSENED during the experiment, do NOT cheerlead it — raise it plainly and route to the doctor / the acute-redflag path. Only ever propose BEHAVIORAL experiments (sleep timing, steps, screen time, meal timing); never a medication / dose / supplement trial — refuse those and defer to the prescriber.`,
);
return parts.join("\n\n");
+2 -2
View File
@@ -18,8 +18,8 @@ import { reasoningTitleOf } from "./reasoning/support";
* never hit the network.
*
* The mock is exported from `src/lib/ai/` so any test can import it,
* but it is NOT registered in `resolveProvider()` — the production
* path must never reach it.
* but no provider resolver returns it: the production path must never
* reach it.
*/
export interface MockAIProviderOptions {
/** Provider tag returned in `CompletionResult.providerType`. Defaults to "local". */
+9 -6
View File
@@ -181,27 +181,30 @@ export function serializeProviderChain(
* reasoning effort from that provider's own settings form.
*
* The setting lives on the chain entry, so a chain that was never customised
* is materialised from the default first. A provider that is not in the chain
* at all gets a DISABLED entry to hold the value: the setting must reach the
* provider when it is resolved as the selected one, and a disabled entry is
* inert everywhere else (every reader filters on `enabled`). Clearing on an
* absent entry writes nothing new.
* is materialised from the default first. With `enable`, which the gateway
* form passes because saving it picks the gateway, the entry is switched on,
* a new one included. Otherwise a provider that is not in the chain gets a
* DISABLED entry to hold the value, inert everywhere but where the provider
* serves as the picked one (`servesPickedGateway`). Clearing on an absent
* entry writes nothing new.
*/
export function chainWithReasoningEffort(
rawChain: unknown,
providerType: ReasoningProviderType,
effort: ReasoningEffort | null,
enable = false,
): ProviderChainEntry[] {
const chain = parseProviderChain(rawChain).map((e) => ({ ...e }));
const entry = chain.find((e) => e.providerType === providerType);
if (entry) {
if (effort) entry.reasoningEffort = effort;
else delete entry.reasoningEffort;
if (enable) entry.enabled = true;
} else if (effort) {
chain.push({
providerType,
priority: Math.max(0, ...chain.map((e) => e.priority)) + 1,
enabled: false,
enabled: enable,
reasoningEffort: effort,
});
}
+19 -19
View File
@@ -25,7 +25,6 @@ import { isPublicUrl } from "@/lib/validations/notifications";
import { isLocalAiHostAllowed } from "./local-host-allowlist";
import {
parseProviderChain,
PROVIDER_CHAIN_DEFAULT,
PROVIDER_CHAIN_TYPES,
type ProviderChainType,
} from "./provider-chain";
@@ -712,7 +711,7 @@ async function resolveAdminCodexProvider(): Promise<AIProvider | null> {
}
/**
* Resolve the AI provider for a given user.
* Test-only, ignoring the chain's switches: use `resolveProviderChain`.
*
* Priority:
* 1. User selected ANTHROPIC / LOCAL with valid creds → that provider.
@@ -812,7 +811,7 @@ async function readBoundSettings(userId: string): Promise<{
* usable credential (e.g. chain says `anthropic` but
* `aiAnthropicKeyEncrypted` is null).
* 3. Append the operator's central Codex behind the person's opt-in.
* 4. Still empty: the picked gateway on a chain never saved
* 4. Still empty: the picked gateway, unless the chain switched it off
* (`servesPickedGateway`), else nothing. Every caller treats an
* empty result as "no provider"; none falls back on its own.
*/
@@ -841,7 +840,7 @@ export async function resolveProviderChain(
aiResponseTimeoutSeconds: true,
},
});
// See `resolveProvider`: every instance carries the record's settings.
// Every instance carries the record's timeout and reasoning effort.
const bind = (provider: AIProvider): AIProvider =>
bindReasoningEffort(
bindResponseTimeout(provider, userRow?.aiResponseTimeoutSeconds),
@@ -964,24 +963,25 @@ function providerOrigin(providerType: string): ProviderManagedBy {
}
/**
* What a chain that resolves empty falls back to. A saved chain is the
* person's own word: an entry they switched off or removed stays out, so an
* empty saved chain means no provider. A chain they never saved is the
* default one (`parseProviderChain` hands back `PROVIDER_CHAIN_DEFAULT`
* itself), which holds every provider a person can pick except the
* OpenAI-compatible gateway; so the gateway they picked under Settings, AI
* serves when nothing in the default chain does. Resolution and the presence
* probe both read this, so they cannot disagree about it.
* What a chain that resolves empty falls back to: the OpenAI-compatible
* gateway picked under Settings, AI, unless the chain switched it off. The
* default chain has no entry for the gateway, so a chain never saved, reset
* to the default, or reordered without it says nothing about the gateway.
* Nor does a disabled gateway entry that carries a reasoning effort: that is
* the holder the gateway form wrote for the setting
* (`chainWithReasoningEffort`), not a switch somebody turned off; the chain
* editor drops the effort of a gateway it switches off. Resolution and the
* presence probe both read this, so they cannot disagree about it.
*/
function servesPickedGateway(row: {
aiProvider: string | null;
aiProviderChain: unknown;
}): boolean {
return (
parseProviderChain(row.aiProviderChain ?? null) ===
PROVIDER_CHAIN_DEFAULT &&
row.aiProvider?.toUpperCase() === "OPENAI_COMPATIBLE"
if (row.aiProvider?.toUpperCase() !== "OPENAI_COMPATIBLE") return false;
const gateway = parseProviderChain(row.aiProviderChain ?? null).find(
(entry) => entry.providerType === "openai-compatible",
);
return !gateway || gateway.enabled || gateway.reasoningEffort !== undefined;
}
/**
@@ -1355,14 +1355,14 @@ async function resolveProviderForTestUnbound(
// Empty selection ("test my saved config" — the ai-section calls
// /api/ai/test with an empty body): resolve via the SAME path generation
// uses — the first entry of `resolveProviderChain`. The legacy codex→admin
// uses, the first entry of `resolveProviderChain`. The legacy codex→admin
// fallback used to probe a different provider than generation ran
// (observed: test→admin-key while generation→codex), so a green test gave
// no signal about whether overnight generation would work.
// Nothing resolving is the route's `ai.provider.none`, not a refused entry.
if (!provider) {
const chain = await resolveProviderChain(userId);
if (chain.length > 0) return chain[0].instance;
throw new AITestConfigError(422, "No AI provider configured");
return chain.length > 0 ? chain[0].instance : new NoProvider();
}
switch (provider) {
+2 -2
View File
@@ -66,8 +66,8 @@ function openAIFamilySupportsVision(model: string): boolean {
/**
* Logical provider tags the OCR path reasons about. Mirrors the
* `ProviderChainType` vocabulary plus the legacy `admin-key` single-provider
* tag `resolveProvider`'s admin fallback returns.
* `ProviderChainType` vocabulary plus the legacy `admin-key` tag the
* operator-key client carries.
*/
export type VisionProviderType =
| "anthropic"
@@ -126,6 +126,21 @@ describe("localizedApiError", () => {
["a rate limit", 429, "http.rate_limited", "too many requests"],
["a missing right", 403, "http.forbidden", "permission"],
["a rejected entry", 422, "http.invalid", "Check them"],
["a mistyped proof", 401, "auth.reproof.failed", "did not verify"],
[
"a password where a second factor is needed",
422,
"auth.reproof.too_weak",
"second factor",
],
[
"an authenticator already set up",
409,
"auth.mfa.already_active",
"Disable it first",
],
["nothing to test with", 422, "ai.provider.none", "No AI provider"],
["a spent AI budget", 429, "ai.budget.exceeded", "budget is used up"],
])("names %s instead of the save fallback", (_, status, code, text) => {
const error = new ApiError("raw English server prose", status, {
errorCode: code,
@@ -157,6 +172,10 @@ describe("localizedApiError", () => {
"http.rate_limited",
"http.forbidden",
"http.invalid",
"auth.reproof.too_weak",
"auth.mfa.already_active",
"ai.provider.none",
"ai.budget.exceeded",
]) {
const sentence = localT(`apiErrors.${code}`);
expect(sentence).not.toBe(`apiErrors.${code}`);
@@ -63,6 +63,53 @@ describe("useIdempotentPost", () => {
expect(keyOf(1)).not.toBe(keyOf(0));
});
it("takes a new key once the server refused the entry", async () => {
// The refusal is stored under the key, so the corrected retry under it
// ran unprotected and an unchanged one replayed the refusal.
fetchMock
.mockResolvedValueOnce(
new Response(
JSON.stringify({ data: null, error: "Name taken", meta: {} }),
{ status: 409, headers: { "Content-Type": "application/json" } },
),
)
.mockResolvedValueOnce(stored());
const post = openForm();
await expect(
post("/api/custom-metrics", { name: "Steps" }),
).rejects.toThrow();
await post("/api/custom-metrics", { name: "Steps" });
expect(keyOf(1)).not.toBe(keyOf(0));
});
it("keeps the key while the first request is still running", async () => {
fetchMock
.mockResolvedValueOnce(
new Response(
JSON.stringify({
data: null,
error: "A request with this Idempotency-Key is already in progress",
}),
{
status: 409,
headers: {
"Content-Type": "application/json",
"X-Idempotent-Replay": "false",
},
},
),
)
.mockResolvedValueOnce(stored());
const post = openForm();
await expect(post("/api/mood-entries", { mood: "GOOD" })).rejects.toThrow();
await post("/api/mood-entries", { mood: "GOOD" });
expect(keyOf(1)).toBe(keyOf(0));
});
it("keeps a header the caller set", async () => {
fetchMock.mockResolvedValueOnce(stored());
const post = openForm();
+9
View File
@@ -79,6 +79,12 @@ export class ApiError extends Error {
* so a form can mark the one the server refused. Empty when it named none.
*/
readonly issuePaths: readonly string[];
/**
* The idempotency layer's "a request under this key is still running" (a
* 409 with `X-Idempotent-Replay: false`): nothing was decided, and a retry
* under the same key replays the first request's answer.
*/
readonly inFlight: boolean;
constructor(
message: string,
@@ -86,12 +92,14 @@ export class ApiError extends Error {
meta?: Record<string, unknown>,
requestId?: string | null,
issuePaths: readonly string[] = [],
inFlight = false,
) {
super(message);
this.name = "ApiError";
this.status = status;
this.meta = meta;
this.issuePaths = issuePaths;
this.inFlight = inFlight;
const fromMeta =
typeof meta?.requestId === "string" && meta.requestId.length > 0
? meta.requestId
@@ -133,6 +141,7 @@ export async function throwApiError(res: Response): Promise<never> {
meta,
res.headers.get("x-request-id"),
issuePaths,
res.status === 409 && res.headers.get("x-idempotent-replay") === "false",
);
// A server-side failure is worth an id in the session's support info,
// whether or not the call site ends up showing it.
+17 -8
View File
@@ -2,19 +2,23 @@
import { useRef } from "react";
import { apiPost } from "@/lib/api/api-fetch";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
import { randomId } from "@/lib/random-id";
/**
* `apiPost` for a create form, under one `Idempotency-Key` until a save lands.
* `apiPost` for a create form, under one `Idempotency-Key` until an answer
* arrives.
*
* The transport mints a fresh key per call, which covers a replayed request
* but not a second tap on Save: when the first answer never arrived (the 15 s
* timeout on a weak signal) the entry is already stored, and the retry is a
* new call with a new key, so the server stored it twice. Every submit
* through this function sends the same key until one succeeds, so the retry
* replays the first answer instead; a retry with a changed body is a
* different request and runs as one. The next entry gets a new key.
* through this function sends the same key until the server answers, so the
* retry replays the first answer instead. An answer settles the key, a
* refusal included: the server keeps a refusal under its key for a day, so a
* corrected retry under the old key was never protected and an unchanged one
* replayed a refusal that no longer held. Only the answer that the first
* request is still running keeps it. The next entry gets a new key.
*/
export function useIdempotentPost() {
const key = useRef<string | null>(null);
@@ -26,8 +30,13 @@ export function useIdempotentPost() {
key.current ??= randomId();
const headers = new Headers(init?.headers);
headers.set("Idempotency-Key", key.current);
const result = await apiPost<T>(path, body, { ...init, headers });
key.current = null;
return result;
try {
const result = await apiPost<T>(path, body, { ...init, headers });
key.current = null;
return result;
} catch (error) {
if (error instanceof ApiError && !error.inFlight) key.current = null;
throw error;
}
};
}
@@ -9,6 +9,7 @@
* account only possession of a factor counts now; the account without one is
* unchanged.
*/
import type { NextRequest } from "next/server";
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
@@ -247,6 +248,20 @@ describe("POST /api/auth/passkey/register-options", () => {
expect(res.status).toBe(200);
expect(refundRateLimit).toHaveBeenCalledWith("auth:step-up:user-1");
});
it("names a mistyped password as a failed proof, not an ended session", async () => {
vi.mocked(requireCookieAuth).mockResolvedValue({
user: stored(PLAIN),
session: { id: "sess-1" },
} as unknown as Awaited<ReturnType<typeof requireCookieAuth>>);
vi.mocked(verifyPassword).mockResolvedValue(false);
const res = await PASSKEY_REGISTER_OPTIONS(
passkeyReq({ method: "password", password: "typo" }) as NextRequest,
);
expect(res.status).toBe(401);
expect((await res.json()).meta.errorCode).toBe("auth.reproof.failed");
expect(createRegistrationOptions).not.toHaveBeenCalled();
});
});
describe("changing the email address", () => {
@@ -71,6 +71,55 @@ describe("applyProfileUpdate locale", () => {
);
});
describe("applyProfileUpdate date of birth", () => {
const writtenDateOfBirth = () =>
(
vi.mocked(prisma.user.update).mock.calls[0]![0]!.data as {
dateOfBirth?: Date | null;
}
).dateOfBirth;
it("stores a typed date as that calendar date", async () => {
await applyProfileUpdate(USER_ID, { dateOfBirth: "2012-05-14" });
expect(writtenDateOfBirth()?.toISOString()).toBe(
"2012-05-14T12:00:00.000Z",
);
});
it("stores an instant from the iOS app as its day in the zone the request sets", async () => {
// 14 May picked at 20:00 in Honolulu (UTC-10).
await applyProfileUpdate(USER_ID, {
dateOfBirth: "2012-05-15T06:00:00Z",
timezone: "Pacific/Honolulu",
});
expect(writtenDateOfBirth()?.toISOString()).toBe(
"2012-05-14T12:00:00.000Z",
);
});
it("reads an instant in the stored zone when the request sets none", async () => {
// 14 May picked at 09:30 in Nuku'alofa (UTC+13). A user id of its own:
// the zone resolver caches per account.
vi.mocked(prisma.user.findUnique).mockResolvedValue({
timezone: "Pacific/Tongatapu",
} as unknown as Awaited<ReturnType<typeof prisma.user.findUnique>>);
await applyProfileUpdate("user-tonga", {
dateOfBirth: "2012-05-13T20:30:00Z",
});
expect(writtenDateOfBirth()?.toISOString()).toBe(
"2012-05-14T12:00:00.000Z",
);
});
it("clears on null and leaves the column alone for a string that names no date", async () => {
await applyProfileUpdate(USER_ID, { dateOfBirth: null });
expect(writtenDateOfBirth()).toBeNull();
vi.mocked(prisma.user.update).mockClear();
await applyProfileUpdate(USER_ID, { dateOfBirth: "2012-02-30" });
expect(writtenDateOfBirth()).toBeUndefined();
});
});
describe("applyProfileUpdate timezone validation", () => {
it("accepts a valid IANA zone", async () => {
const result = await applyProfileUpdate(USER_ID, {
+18 -1
View File
@@ -7,7 +7,9 @@ import { optionalDisplayNameSchema } from "@/lib/validations/display-name";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { profileSchema } from "@/lib/validations/auth";
import { dateOfBirthForStorage } from "@/lib/tz/date-only";
import { isValidTimezone } from "@/lib/tz/format";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { encrypt } from "@/lib/crypto";
import {
invalidateUserMedications,
@@ -187,6 +189,21 @@ export interface ApplyProfileError {
issues?: SanitisedZodIssue[];
}
/**
* The date of birth to store: the owner's calendar day (the iOS app sends an
* instant, read in the zone this request sets or the stored one), null to
* clear, and undefined, which leaves the column alone, for a string that
* names no date.
*/
async function storedDateOfBirth(
userId: string,
data: Partial<ExtendedProfileInput>,
): Promise<Date | null | undefined> {
if (!data.dateOfBirth) return null;
const timezone = data.timezone ?? (await resolveUserTimezone(userId));
return dateOfBirthForStorage(data.dateOfBirth, timezone) ?? undefined;
}
/**
* Validate and persist profile updates for `userId`. Returns either an
* `ApplyProfileResult` on success or `ApplyProfileError` with a status
@@ -434,7 +451,7 @@ export async function applyProfileUpdate(
if (data.email !== undefined) updates.email = normalizedEmail;
if (data.heightCm !== undefined) updates.heightCm = data.heightCm ?? null;
if (data.dateOfBirth !== undefined) {
updates.dateOfBirth = data.dateOfBirth ? new Date(data.dateOfBirth) : null;
updates.dateOfBirth = await storedDateOfBirth(userId, data);
}
if (data.gender !== undefined) updates.gender = data.gender;
if (data.displayName !== undefined) {
+1
View File
@@ -86,6 +86,7 @@ export interface DoctorReportData {
* login, never a managed record's generated handle. Null prints no line.
*/
name: string | null;
/** The calendar date, `YYYY-MM-DD` (`dateOfBirthKey`). */
dateOfBirth: string | null;
gender: string | null;
heightCm: number | null;
@@ -500,6 +500,35 @@ describe("the name the report prints", () => {
});
});
describe("the date of birth the report prints", () => {
const identity = selectionFromLeaves(["PATIENT_IDENTITY"]);
const born = async (dateOfBirth: Date, timezone: string) => {
rows.userUnique = [
{ ...(rows.userUnique[0] as object), dateOfBirth, timezone },
];
const payload = await collectDoctorReportData("u1", RANGE, identity, {
moduleMap: moduleMap(),
});
return payload.patient.dateOfBirth;
};
it("prints a date stored at UTC midnight as that date in every zone", async () => {
const stored = new Date("1960-05-14T00:00:00.000Z");
expect(await born(stored, "Pacific/Honolulu")).toBe("1960-05-14");
expect(await born(stored, "Pacific/Tongatapu")).toBe("1960-05-14");
});
it("prints an instant from the iOS app as its date in the owner's zone", async () => {
// 14 May entered at 00:30 in Berlin, and at 20:00 in Honolulu.
expect(
await born(new Date("1960-05-13T23:30:00.000Z"), "Europe/Berlin"),
).toBe("1960-05-14");
expect(
await born(new Date("1960-05-15T06:00:00.000Z"), "Pacific/Honolulu"),
).toBe("1960-05-14");
});
});
describe("fail-closed exclusion", () => {
it("excludes a measurement type the catalogue does not know", () => {
const gate = resolveReportGate(
+2 -2
View File
@@ -83,7 +83,7 @@ import {
loadSurgicalHistory,
loadLabResults,
} from "./clinical-records";
import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
import { dateOfBirthKey, dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
import { PATIENT_NAME_SELECT, patientName } from "@/lib/account-name";
import { readingsMean } from "@/lib/measurements/day-mean";
import { loadScreenerUnvalidatedTranslations } from "./screener-translations";
@@ -723,7 +723,7 @@ export async function collectDoctorReportData(
name: identityOn && userProfile ? patientName(userProfile) : null,
dateOfBirth:
identityOn && userProfile?.dateOfBirth
? userProfile.dateOfBirth.toISOString()
? dateOfBirthKey(userProfile.dateOfBirth, reportTz)
: null,
gender: identityOn ? (userProfile?.gender ?? null) : null,
heightCm: identityOn ? (userProfile?.heightCm ?? null) : null,
@@ -346,7 +346,7 @@ describe("generateBloodPressureStatusForUser — no rate without an expectation"
});
});
describe("generateBloodPressureStatusForUser — the person's own target", () => {
describe("generateBloodPressureStatusForUser and the person's own target", () => {
const readings = [1, 2, 3].flatMap((daysAgo) => {
const measuredAt = new Date(Date.now() - daysAgo * dayMs);
return [
+1 -1
View File
@@ -422,7 +422,7 @@ describe("extractFeatures — weight target direction (#1006)", () => {
});
});
describe("extractFeatures — blood pressure against the person's own target", () => {
describe("extractFeatures: blood pressure against the person's own target", () => {
// 135/85 on three days: above the age default's ceiling, inside a target
// the person's doctor agreed (up to 140/90).
const bpRows = [1, 2, 3].flatMap((daysAgo) => {
@@ -1,6 +1,9 @@
import { describe, expect, it, vi } from "vitest";
import { waitForMedicationIntakeImport } from "../intake-import-poll";
import {
MedicationIntakeImportTimeout,
waitForMedicationIntakeImport,
} from "../intake-import-poll";
describe("waitForMedicationIntakeImport", () => {
it("polls queued and running jobs until the durable result is done", async () => {
@@ -53,4 +56,19 @@ describe("waitForMedicationIntakeImport", () => {
expect(readStatus).toHaveBeenCalledOnce();
expect(wait).not.toHaveBeenCalled();
});
it("tells a watch that gave up from a job that failed", async () => {
// The job may still write, so the card must not say nothing was written.
const readStatus = vi.fn().mockResolvedValue({
status: "running",
result: null,
});
await expect(
waitForMedicationIntakeImport("/api/import/job-1/status", {
readStatus,
wait: vi.fn().mockResolvedValue(undefined),
maxAttempts: 2,
}),
).rejects.toBeInstanceOf(MedicationIntakeImportTimeout);
});
});
+9 -1
View File
@@ -29,6 +29,14 @@ export class MedicationIntakeImportError extends Error {
}
}
/** The job did not settle while the client watched; it may still write. */
export class MedicationIntakeImportTimeout extends MedicationIntakeImportError {
constructor() {
super("Medication intake import timed out");
this.name = "MedicationIntakeImportTimeout";
}
}
const waitForNextPoll = () =>
new Promise<void>((resolve) => setTimeout(resolve, 1_000));
@@ -52,5 +60,5 @@ export async function waitForMedicationIntakeImport(
if (attempt + 1 < maxAttempts) await wait();
}
throw new MedicationIntakeImportError("Medication intake import timed out");
throw new MedicationIntakeImportTimeout();
}
+2
View File
@@ -86,6 +86,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"auth.account.deletion_pending",
"auth.admin.required",
"auth.already_authenticated",
"auth.mfa.already_active",
"auth.mfa.code_invalid",
"auth.missing",
"auth.passkey.last_method",
@@ -140,6 +141,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"coach-prefs.body.invalid_shape",
],
consent: ["consent.ai.confirmRequired", "consent.ai.required"],
customMetric: ["customMetric.name_taken"],
cycle: [
"cycle.bulk.invalid",
"cycle.bulk.too_large",
+1 -1
View File
@@ -565,7 +565,7 @@ export const insightsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Insights"],
summary: "Per-metric target tiles + consistency strips",
description:
"The whole targets page in one read: a tile per metric with its band, its current and 30-day-average values, and the seven-day consistency strip behind it, plus the page summary, the diastolic companion figures 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.",
"The whole targets page in one read: a tile per metric with its band, its current and 30-day-average values, and the seven-day consistency strip behind it, plus the page summary, the diastolic companion figures 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 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: {
...recordRefusal(),
"200": {
Loaded 100 of 112 files, more files were not shown because too many files have changed in this diff. Show more