fix(copy): make delete confirmations, sleep-debt labels, and the glucose band honest

Three surfaces where the words no longer matched the code, plus the missing
web control for a preference the server already honours.

Delete confirmations. Four dialogs promised permanence on surfaces that
render an Undo: the measurements and mood bulk bodies, the lab-result body,
and the illness-episode body (which said "cannot be undone" sixty lines
below the code that fires the Undo toast). All four now use the same
sentence the single-row measurement and mood dialogs already used. A sweep
of every other confirmation in the tree found the rest accurate — the
biomarker cascade, the mood-tag purge, and the account purge keep their
warning because those deletes really are unrecoverable.

Sleep debt. v1.19.0 replaced the summed shortfall with a running balance
that credits catch-up sleep; the labels still described the old sum, so the
word contradicted the number for anyone who slept in after a short night.
Corrected in all six locales, plus the places the old semantics had
propagated: the Coach system prompt, the OpenAPI field descriptions the
native client reads, and four module docblocks.

Glucose reference band. `hasDiabetes` selects the tighter reference band and
grounds the Coach, but no web surface set it — a web-only account was pinned
to the non-diabetic scale with no way out. The control goes on Targets,
above the per-metric editor, because it answers the same question that page
answers and the two are ordered: it picks the default band, an override
below beats it. The copy says what it is — a reference band, not a
diagnosis, never inferred from a reading, no clinical action taken on it.

The passkey list was reported as always reading "never used". It does not
reproduce: the column is selected by the list route and stamped on every
verified assertion, both since v1.23.0. Pinned with tests on both halves so
the contract cannot quietly regress.
This commit is contained in:
Marc-André Bombeck committed 2026-07-19 12:28:05 +02:00
1 parent b0021a8258
commit e9d28b399e
21 files changed
+601 -61

No files matched your search

+11 -10
View File
@@ -2959,11 +2959,11 @@ paths:
- Measurements
summary: Sleep rhythm — sleep-debt + chronotype (v1.17.0)
description: "Returns the two server-authoritative sleep-timing signals the Sleep page + iOS render off the same
canonical night reconstruction the Sleep Score uses: cumulative `sleepDebt` over the rolling window (calm
`partial` state under the night threshold) and MCTQ `chronotype` (MSF/MSFsc band + social jetlag, `learning`
state until enough free-day nights exist). Free vs work nights default to weekend = free in the user's timezone
(no work calendar). A read-only view over existing per-stage SLEEP_DURATION rows — no schema, no new measurement
type."
canonical night reconstruction the Sleep Score uses: the outstanding `sleepDebt` balance over the rolling window
(calm `partial` state under the night threshold) and MCTQ `chronotype` (MSF/MSFsc band + social jetlag,
`learning` state until enough free-day nights exist). Free vs work nights default to weekend = free in the
user's timezone (no work calendar). A read-only view over existing per-stage SLEEP_DURATION rows — no schema, no
new measurement type."
responses:
"200":
description: Sleep-debt + chronotype DTO.
@@ -17930,12 +17930,13 @@ components:
- partial
- ready
description: "`partial` until enough tracked nights exist (calm 'still learning'); `ready` once the rolling window can
assert a cumulative debt."
assert a balance."
debtMinutes:
type: integer
minimum: 0
maximum: 9007199254740991
description: Cumulative sleep debt over the window, in minutes, after caps.
description: Outstanding sleep-debt balance over the window, in minutes, after caps. A night below need adds its
shortfall; a night above need pays the balance down (floored at 0).
needMinutes:
type: integer
minimum: 0
@@ -18076,9 +18077,9 @@ components:
- chronotype
- averagePerNight
additionalProperties: false
description: "Server-authoritative sleep-rhythm read: cumulative sleep debt over the rolling window + MCTQ chronotype
(MSF/MSFsc band, social jetlag). Free vs work nights default to weekend = free in the user's timezone (no work
calendar). A view over existing per-stage SLEEP_DURATION rows — no schema, no new type."
description: "Server-authoritative sleep-rhythm read: the outstanding sleep-debt balance over the rolling window + MCTQ
chronotype (MSF/MSFsc band, social jetlag). Free vs work nights default to weekend = free in the user's timezone
(no work calendar). A view over existing per-stage SLEEP_DURATION rows — no schema, no new type."
ListWorkoutsResponse:
type: object
properties:
+14 -6
View File
@@ -578,7 +578,7 @@
"bulkDeleteSuccess": "{count} Messungen gelöscht",
"bulkDeleteError": "Fehler beim Löschen der ausgewählten Messungen",
"bulkDeleteConfirmTitle": "{count} Messungen löschen?",
"bulkDeleteConfirmBody": "Die {count} ausgewählten Messungen werden endgültig gelöscht.",
"bulkDeleteConfirmBody": "Die {count} ausgewählten Messungen werden gelöscht. Direkt danach kannst du das kurz rückgängig machen.",
"typePhq9Score": "PHQ-9-Wert",
"typeGad7Score": "GAD-7-Wert",
"typeGripStrength": "Griffstärke",
@@ -726,7 +726,7 @@
"bulkDeleteSuccess": "{count} Einträge gelöscht",
"bulkDeleteError": "Fehler beim Löschen der ausgewählten Einträge",
"bulkDeleteConfirmTitle": "{count} Einträge löschen?",
"bulkDeleteConfirmBody": "Die {count} ausgewählten Stimmungseinträge werden endgültig gelöscht.",
"bulkDeleteConfirmBody": "Die {count} ausgewählten Stimmungseinträge werden gelöscht. Direkt danach kannst du das kurz rückgängig machen.",
"customize": "Stimmungs-Tags anpassen",
"addTagInline": "Tag hinzufügen",
"manage": {
@@ -2862,9 +2862,9 @@
"debt": {
"title": "Schlafschuld",
"value": "{hours} Std. {minutes} Min. zu wenig",
"caption": "Kumuliertes Defizit der letzten {nights} Nächte",
"caption": "Offene Bilanz der letzten {nights} Nächte",
"clearValue": "Ausgeglichen",
"clearCaption": "Kein aufgelaufenes Defizit über die letzten {nights} Nächte",
"clearCaption": "Nichts offen über die letzten {nights} Nächte",
"learning": "Dein Schlaf wird noch erfasst — {counted} Nächte bisher, noch {remaining} für eine verlässliche Bilanz.",
"computedInfo": "Das ist HealthLogs eigene Schätzung: eine gleitende Bilanz aus Bedarf minus Schlaf über die letzten Nächte, die Aufholschlaf wieder abbaut. Sie kann vom nativen Schlafdefizit eines Wearables abweichen, das ein eigenes Bedarfsmodell nutzt."
},
@@ -5733,6 +5733,14 @@
"title": "Garmin",
"body": "Garmin hat hier keine direkte Verbindung. Verbinde über Apple Health (iOS) oder Google Health Connect (Android). Garmins proprietäre Werte — Body Battery, Training Load, HRV-Status, VO₂max — werden von Garmin an keine der beiden Plattformen weitergegeben.",
"docsLink": "Zur Anleitung"
},
"glucoseReference": {
"title": "Glukose-Referenzband",
"description": "Gegen welches Band deine Blutzuckerwerte bewertet werden.",
"enable": "Diabetes-Zielband verwenden",
"explainer": "Aus werden Werte gegen das allgemeine nicht-diabetische Referenzband bewertet. An werden sie gegen das engere glykämische Zielband der ADA Standards of Care bewertet, und der Coach begründet seine Glukose-Aussagen auf diesem Band. Deine eigenen Zielwert-Überschreibungen unten haben weiterhin Vorrang.",
"disclaimer": "Das wählt ein Referenzband aus. Es ist keine Diagnose, wird nie aus einem Messwert abgeleitet, und die App handelt klinisch nicht danach. Welche Zielwerte für dich gelten, klärst du mit deiner Ärztin oder deinem Arzt.",
"error": "Die Einstellung konnte nicht gespeichert werden."
}
},
"admin": {
@@ -7471,7 +7479,7 @@
"referenceLabel": "Referenz",
"readingsCount": "{count} Messungen",
"deleteConfirmTitle": "Laborwert löschen?",
"deleteConfirmDescription": "Dieser Laborwert wird dauerhaft gelöscht.",
"deleteConfirmDescription": "Dieser Laborwert wird gelöscht. Direkt danach kannst du das kurz rückgängig machen.",
"deletedToast": "Wert gelöscht",
"deleteError": "Wert konnte nicht gelöscht werden",
"loadError": "Laborwerte konnten nicht geladen werden",
@@ -7950,7 +7958,7 @@
},
"deleteConfirm": {
"title": "Diese Episode löschen?",
"body": "„{label}“ und alle zugehörigen Tageseinträge werden entfernt. Dies kann nicht rückgängig gemacht werden."
"body": "„{label}“ und alle zugehörigen Tageseinträge werden entfernt. Direkt danach kannst du das kurz rückgängig machen."
},
"vital": {
"RESTING_HEART_RATE": "Ruheherzfrequenz",
+14 -6
View File
@@ -578,7 +578,7 @@
"bulkDeleteSuccess": "{count} measurements deleted",
"bulkDeleteError": "Error deleting the selected measurements",
"bulkDeleteConfirmTitle": "Delete {count} measurements?",
"bulkDeleteConfirmBody": "The {count} selected measurements will be permanently deleted.",
"bulkDeleteConfirmBody": "The {count} selected measurements will be deleted. You can undo this for a short moment afterwards.",
"typePhq9Score": "PHQ-9 score",
"typeGad7Score": "GAD-7 score",
"typeGripStrength": "Grip strength",
@@ -726,7 +726,7 @@
"bulkDeleteSuccess": "{count} entries deleted",
"bulkDeleteError": "Error deleting the selected entries",
"bulkDeleteConfirmTitle": "Delete {count} entries?",
"bulkDeleteConfirmBody": "The {count} selected mood entries will be permanently deleted.",
"bulkDeleteConfirmBody": "The {count} selected mood entries will be deleted. You can undo this for a short moment afterwards.",
"customize": "Customize mood tags",
"addTagInline": "Add tag",
"manage": {
@@ -2862,9 +2862,9 @@
"debt": {
"title": "Sleep debt",
"value": "{hours}h {minutes}m short",
"caption": "Cumulative shortfall over the last {nights} nights",
"caption": "Outstanding balance over the last {nights} nights",
"clearValue": "All caught up",
"clearCaption": "No accumulated shortfall over the last {nights} nights",
"clearCaption": "Nothing outstanding over the last {nights} nights",
"learning": "Still learning your sleep — {counted} nights so far, {remaining} more to read a reliable balance.",
"computedInfo": "This is HealthLog's own estimate: a rolling balance of need minus sleep over recent nights, with catch-up sleep paying it down. It can differ from a wearable's native sleep-debt figure, which uses its own need model."
},
@@ -5733,6 +5733,14 @@
"title": "Garmin",
"body": "Garmin has no direct connection here. Connect via Apple Health (iOS) or Google Health Connect (Android). Garmin's proprietary scores — Body Battery, Training Load, HRV Status, VO₂ max — are not shared by Garmin to either platform.",
"docsLink": "Read the guide"
},
"glucoseReference": {
"title": "Glucose reference band",
"description": "Which band your blood-glucose readings are judged against.",
"enable": "Use the diabetes goal band",
"explainer": "Off, readings are judged against the general non-diabetic reference band. On, they are judged against the tighter glycaemic goal band from the ADA Standards of Care, and the Coach grounds its glucose remarks on that band instead. Your own target overrides below still take precedence.",
"disclaimer": "This selects a reference band. It is not a diagnosis, it is never inferred from a reading, and the app takes no clinical action on it. Which targets apply to you is a question for your clinician.",
"error": "Could not save the setting."
}
},
"admin": {
@@ -7471,7 +7479,7 @@
"referenceLabel": "Reference",
"readingsCount": "{count} readings",
"deleteConfirmTitle": "Delete lab result?",
"deleteConfirmDescription": "This lab result will be permanently deleted.",
"deleteConfirmDescription": "This lab result will be deleted. You can undo this for a short moment afterwards.",
"deletedToast": "Result deleted",
"deleteError": "Could not delete the result",
"loadError": "Could not load lab results",
@@ -7950,7 +7958,7 @@
},
"deleteConfirm": {
"title": "Delete this episode?",
"body": "“{label}” and all its day-logs will be removed. This cannot be undone."
"body": "“{label}” and all its day-logs will be removed. You can undo this for a short moment afterwards."
},
"vital": {
"RESTING_HEART_RATE": "Resting heart rate",
+14 -6
View File
@@ -578,7 +578,7 @@
"bulkDeleteSuccess": "{count} mediciones eliminadas",
"bulkDeleteError": "Error al eliminar las mediciones seleccionadas",
"bulkDeleteConfirmTitle": "¿Eliminar {count} mediciones?",
"bulkDeleteConfirmBody": "Las {count} mediciones seleccionadas se eliminarán de forma permanente.",
"bulkDeleteConfirmBody": "Las {count} mediciones seleccionadas se eliminarán. Justo después podrás deshacerlo durante un breve momento.",
"typePhq9Score": "Puntuación PHQ-9",
"typeGad7Score": "Puntuación GAD-7",
"typeGripStrength": "Fuerza de agarre",
@@ -726,7 +726,7 @@
"bulkDeleteSuccess": "{count} entradas eliminadas",
"bulkDeleteError": "Error al eliminar las entradas seleccionadas",
"bulkDeleteConfirmTitle": "¿Eliminar {count} entradas?",
"bulkDeleteConfirmBody": "Las {count} entradas de estado de ánimo seleccionadas se eliminarán de forma permanente.",
"bulkDeleteConfirmBody": "Las {count} entradas de estado de ánimo seleccionadas se eliminarán. Justo después podrás deshacerlo durante un breve momento.",
"customize": "Personalizar etiquetas de ánimo",
"addTagInline": "Añadir etiqueta",
"manage": {
@@ -2862,9 +2862,9 @@
"debt": {
"title": "Deuda de sueño",
"value": "{hours} h {minutes} min de déficit",
"caption": "Déficit acumulado de las últimas {nights} noches",
"caption": "Balance pendiente de las últimas {nights} noches",
"clearValue": "Al día",
"clearCaption": "Sin déficit acumulado en las últimas {nights} noches",
"clearCaption": "Nada pendiente en las últimas {nights} noches",
"learning": "Aún estamos midiendo tu sueño — {counted} noches hasta ahora, faltan {remaining} para un balance fiable.",
"computedInfo": "Esta es la estimación propia de HealthLog: un balance móvil de la necesidad menos el sueño de las últimas noches, que el sueño de recuperación va reduciendo. Puede diferir de la cifra de deuda de sueño nativa de un wearable, que usa su propio modelo de necesidad."
},
@@ -5733,6 +5733,14 @@
"title": "Garmin",
"body": "Garmin no tiene conexión directa aquí. Conéctalo a través de Apple Health (iOS) o Google Health Connect (Android). Las métricas propias de Garmin — Body Battery, Training Load, estado de HRV, VO₂ máx.— no las comparte Garmin con ninguna de las dos plataformas.",
"docsLink": "Leer la guía"
},
"glucoseReference": {
"title": "Banda de referencia de glucosa",
"description": "Con qué banda se evalúan tus valores de glucosa en sangre.",
"enable": "Usar la banda objetivo para diabetes",
"explainer": "Desactivado, los valores se evalúan con la banda de referencia general no diabética. Activado, se evalúan con la banda objetivo glucémica más estrecha de los ADA Standards of Care, y el Coach fundamenta sus comentarios sobre glucosa en esa banda. Tus propios valores objetivo personalizados de abajo siguen teniendo prioridad.",
"disclaimer": "Esto selecciona una banda de referencia. No es un diagnóstico, nunca se deduce de una medición y la aplicación no actúa clínicamente en consecuencia. Qué objetivos se aplican a ti es una pregunta para tu profesional sanitario.",
"error": "No se pudo guardar el ajuste."
}
},
"admin": {
@@ -7471,7 +7479,7 @@
"referenceLabel": "Referencia",
"readingsCount": "{count} mediciones",
"deleteConfirmTitle": "¿Eliminar resultado de laboratorio?",
"deleteConfirmDescription": "Este resultado de laboratorio se eliminará de forma permanente.",
"deleteConfirmDescription": "Este resultado de laboratorio se eliminará. Justo después podrás deshacerlo durante un breve momento.",
"deletedToast": "Resultado eliminado",
"deleteError": "No se pudo eliminar el resultado",
"loadError": "No se pudieron cargar los resultados",
@@ -7950,7 +7958,7 @@
},
"deleteConfirm": {
"title": "¿Eliminar este episodio?",
"body": "«{label}» y todos sus registros diarios se eliminarán. Esta acción no se puede deshacer."
"body": "«{label}» y todos sus registros diarios se eliminarán. Justo después podrás deshacerlo durante un breve momento."
},
"vital": {
"RESTING_HEART_RATE": "Frecuencia cardíaca en reposo",
+14 -6
View File
@@ -578,7 +578,7 @@
"bulkDeleteSuccess": "{count} mesures supprimées",
"bulkDeleteError": "Erreur lors de la suppression des mesures sélectionnées",
"bulkDeleteConfirmTitle": "Supprimer {count} mesures ?",
"bulkDeleteConfirmBody": "Les {count} mesures sélectionnées seront définitivement supprimées.",
"bulkDeleteConfirmBody": "Les {count} mesures sélectionnées seront supprimées. Vous pourrez annuler cette action pendant un court instant juste après.",
"typePhq9Score": "Score PHQ-9",
"typeGad7Score": "Score GAD-7",
"typeGripStrength": "Force de préhension",
@@ -726,7 +726,7 @@
"bulkDeleteSuccess": "{count} entrées supprimées",
"bulkDeleteError": "Erreur lors de la suppression des entrées sélectionnées",
"bulkDeleteConfirmTitle": "Supprimer {count} entrées ?",
"bulkDeleteConfirmBody": "Les {count} entrées d'humeur sélectionnées seront définitivement supprimées.",
"bulkDeleteConfirmBody": "Les {count} entrées d'humeur sélectionnées seront supprimées. Vous pourrez annuler cette action pendant un court instant juste après.",
"customize": "Personnaliser les étiquettes d’humeur",
"addTagInline": "Ajouter une étiquette",
"manage": {
@@ -2862,9 +2862,9 @@
"debt": {
"title": "Dette de sommeil",
"value": "{hours} h {minutes} min de déficit",
"caption": "Déficit cumulé sur les {nights} dernières nuits",
"caption": "Solde restant sur les {nights} dernières nuits",
"clearValue": "À jour",
"clearCaption": "Aucun déficit cumulé sur les {nights} dernières nuits",
"clearCaption": "Rien en attente sur les {nights} dernières nuits",
"learning": "Votre sommeil est encore en cours d'analyse — {counted} nuits jusqu'ici, encore {remaining} pour un bilan fiable.",
"computedInfo": "Il s'agit de l'estimation propre à HealthLog : un solde glissant entre le besoin et le sommeil des dernières nuits, que le sommeil de récupération réduit. Elle peut différer de la dette de sommeil native d'un wearable, qui utilise son propre modèle de besoin."
},
@@ -5733,6 +5733,14 @@
"title": "Garmin",
"body": "Garmin n'a pas de connexion directe ici. Connecte-le via Apple Health (iOS) ou Google Health Connect (Android). Les scores propriétaires de Garmin — Body Battery, Training Load, statut VFC, VO₂ max — ne sont partagés par Garmin avec aucune des deux plateformes.",
"docsLink": "Lire le guide"
},
"glucoseReference": {
"title": "Plage de référence du glucose",
"description": "La plage à laquelle vos glycémies sont comparées.",
"enable": "Utiliser la plage cible diabète",
"explainer": "Désactivé, les valeurs sont comparées à la plage de référence générale non diabétique. Activé, elles sont comparées à la plage cible glycémique plus stricte des ADA Standards of Care, et le Coach fonde ses remarques sur le glucose sur cette plage. Vos propres valeurs cibles personnalisées ci-dessous restent prioritaires.",
"disclaimer": "Ceci sélectionne une plage de référence. Ce n'est pas un diagnostic, ce n'est jamais déduit d'une mesure, et l'application n'en tire aucune conséquence clinique. Les cibles qui s'appliquent à vous relèvent de votre médecin.",
"error": "Le réglage n'a pas pu être enregistré."
}
},
"admin": {
@@ -7471,7 +7479,7 @@
"referenceLabel": "Référence",
"readingsCount": "{count} mesures",
"deleteConfirmTitle": "Supprimer le résultat d'analyse ?",
"deleteConfirmDescription": "Ce résultat d'analyse sera définitivement supprimé.",
"deleteConfirmDescription": "Ce résultat d'analyse sera supprimé. Vous pourrez annuler cette action pendant un court instant juste après.",
"deletedToast": "Résultat supprimé",
"deleteError": "Impossible de supprimer le résultat",
"loadError": "Impossible de charger les résultats",
@@ -7950,7 +7958,7 @@
},
"deleteConfirm": {
"title": "Supprimer cet épisode ?",
"body": "« {label} » et tous ses relevés quotidiens seront supprimés. Cette action est irréversible."
"body": "« {label} » et tous ses relevés quotidiens seront supprimés. Vous pourrez annuler cette action pendant un court instant juste après."
},
"vital": {
"RESTING_HEART_RATE": "Fréquence cardiaque au repos",
+14 -6
View File
@@ -578,7 +578,7 @@
"bulkDeleteSuccess": "{count} misurazioni eliminate",
"bulkDeleteError": "Errore durante l'eliminazione delle misurazioni selezionate",
"bulkDeleteConfirmTitle": "Eliminare {count} misurazioni?",
"bulkDeleteConfirmBody": "Le {count} misurazioni selezionate verranno eliminate definitivamente.",
"bulkDeleteConfirmBody": "Le {count} misurazioni selezionate verranno eliminate. Subito dopo potrai annullare l’operazione per un breve momento.",
"typePhq9Score": "Punteggio PHQ-9",
"typeGad7Score": "Punteggio GAD-7",
"typeGripStrength": "Forza di presa",
@@ -726,7 +726,7 @@
"bulkDeleteSuccess": "{count} voci eliminate",
"bulkDeleteError": "Errore durante l'eliminazione delle voci selezionate",
"bulkDeleteConfirmTitle": "Eliminare {count} voci?",
"bulkDeleteConfirmBody": "Le {count} voci dell'umore selezionate verranno eliminate definitivamente.",
"bulkDeleteConfirmBody": "Le {count} voci dell'umore selezionate verranno eliminate. Subito dopo potrai annullare l’operazione per un breve momento.",
"customize": "Personalizza i tag dell’umore",
"addTagInline": "Aggiungi tag",
"manage": {
@@ -2862,9 +2862,9 @@
"debt": {
"title": "Debito di sonno",
"value": "{hours} h {minutes} min in meno",
"caption": "Deficit cumulato nelle ultime {nights} notti",
"caption": "Bilancio in sospeso nelle ultime {nights} notti",
"clearValue": "In pari",
"clearCaption": "Nessun deficit accumulato nelle ultime {nights} notti",
"clearCaption": "Niente in sospeso nelle ultime {nights} notti",
"learning": "Stiamo ancora misurando il tuo sonno — {counted} notti finora, ne mancano {remaining} per un bilancio affidabile.",
"computedInfo": "Questa è la stima propria di HealthLog: un bilancio mobile tra fabbisogno e sonno delle ultime notti, che il sonno di recupero riduce. Può differire dal dato di debito di sonno nativo di un dispositivo indossabile, che usa un proprio modello di fabbisogno."
},
@@ -5733,6 +5733,14 @@
"title": "Garmin",
"body": "Garmin non ha una connessione diretta qui. Collegalo tramite Apple Health (iOS) o Google Health Connect (Android). I punteggi proprietari di Garmin — Body Battery, Training Load, stato HRV, VO₂ max — non vengono condivisi da Garmin con nessuna delle due piattaforme.",
"docsLink": "Leggi la guida"
},
"glucoseReference": {
"title": "Banda di riferimento della glicemia",
"description": "La banda con cui vengono valutati i tuoi valori glicemici.",
"enable": "Usa la banda obiettivo per il diabete",
"explainer": "Disattivato, i valori vengono valutati con la banda di riferimento generale non diabetica. Attivato, vengono valutati con la banda obiettivo glicemica più stretta degli ADA Standards of Care, e il Coach fonda su quella banda le sue osservazioni sulla glicemia. I tuoi valori obiettivo personalizzati qui sotto hanno comunque la precedenza.",
"disclaimer": "Questo seleziona una banda di riferimento. Non è una diagnosi, non viene mai dedotto da una misurazione e l'app non agisce clinicamente di conseguenza. Quali obiettivi valgano per te è una domanda per il tuo medico.",
"error": "Non è stato possibile salvare l'impostazione."
}
},
"admin": {
@@ -7471,7 +7479,7 @@
"referenceLabel": "Riferimento",
"readingsCount": "{count} misurazioni",
"deleteConfirmTitle": "Eliminare il risultato di laboratorio?",
"deleteConfirmDescription": "Questo risultato di laboratorio verrà eliminato definitivamente.",
"deleteConfirmDescription": "Questo risultato di laboratorio verrà eliminato. Subito dopo potrai annullare l’operazione per un breve momento.",
"deletedToast": "Risultato eliminato",
"deleteError": "Impossibile eliminare il risultato",
"loadError": "Impossibile caricare i risultati",
@@ -7950,7 +7958,7 @@
},
"deleteConfirm": {
"title": "Eliminare questo episodio?",
"body": "«{label}» e tutti i relativi registri giornalieri verranno rimossi. L'operazione non può essere annullata."
"body": "«{label}» e tutti i relativi registri giornalieri verranno rimossi. Subito dopo potrai annullare l’operazione per un breve momento."
},
"vital": {
"RESTING_HEART_RATE": "Frequenza cardiaca a riposo",
+14 -6
View File
@@ -578,7 +578,7 @@
"bulkDeleteSuccess": "Usunięto pomiary: {count}",
"bulkDeleteError": "Błąd podczas usuwania wybranych pomiarów",
"bulkDeleteConfirmTitle": "Usunąć pomiary ({count})?",
"bulkDeleteConfirmBody": "Wybrane pomiary ({count}) zostaną trwale usunięte.",
"bulkDeleteConfirmBody": "Wybrane pomiary ({count}) zostaną usunięte. Zaraz po usunięciu możesz to przez chwilę cofnąć.",
"typePhq9Score": "Wynik PHQ-9",
"typeGad7Score": "Wynik GAD-7",
"typeGripStrength": "Siła chwytu",
@@ -726,7 +726,7 @@
"bulkDeleteSuccess": "Usunięto wpisy: {count}",
"bulkDeleteError": "Błąd podczas usuwania wybranych wpisów",
"bulkDeleteConfirmTitle": "Usunąć wpisy ({count})?",
"bulkDeleteConfirmBody": "Wybrane wpisy nastroju ({count}) zostaną trwale usunięte.",
"bulkDeleteConfirmBody": "Wybrane wpisy nastroju ({count}) zostaną usunięte. Zaraz po usunięciu możesz to przez chwilę cofnąć.",
"customize": "Dostosuj tagi nastroju",
"addTagInline": "Dodaj tag",
"manage": {
@@ -2862,9 +2862,9 @@
"debt": {
"title": "Dług snu",
"value": "{hours} godz. {minutes} min niedoboru",
"caption": "Skumulowany niedobór z ostatnich {nights} nocy",
"caption": "Pozostały bilans z ostatnich {nights} nocy",
"clearValue": "Na bieżąco",
"clearCaption": "Brak skumulowanego niedoboru w ostatnich {nights} nocach",
"clearCaption": "Nic do nadrobienia w ostatnich {nights} nocach",
"learning": "Wciąż analizujemy Twój sen — {counted} nocy do tej pory, jeszcze {remaining}, aby uzyskać wiarygodny bilans.",
"computedInfo": "To własne oszacowanie HealthLog: ruchomy bilans potrzeby snu minus sen z ostatnich nocy, który sen nadrabiający zmniejsza. Może różnić się od natywnego wskaźnika długu snu z urządzenia, które korzysta z własnego modelu zapotrzebowania."
},
@@ -5733,6 +5733,14 @@
"title": "Garmin",
"body": "Garmin nie ma tutaj bezpośredniego połączenia. Połącz przez Apple Health (iOS) lub Google Health Connect (Android). Zastrzeżone wskaźniki Garmina — Body Battery, Training Load, status HRV, VO₂ max — nie są udostępniane przez Garmina żadnej z tych platform.",
"docsLink": "Przeczytaj przewodnik"
},
"glucoseReference": {
"title": "Zakres referencyjny glukozy",
"description": "Do jakiego zakresu porównywane są twoje wyniki glukozy we krwi.",
"enable": "Użyj zakresu docelowego dla cukrzycy",
"explainer": "Wyłączone — wyniki są oceniane względem ogólnego zakresu referencyjnego dla osób bez cukrzycy. Włączone — są oceniane względem węższego zakresu docelowego glikemii z ADA Standards of Care, a Coach opiera na nim swoje uwagi o glukozie. Twoje własne wartości docelowe poniżej nadal mają pierwszeństwo.",
"disclaimer": "To wybiera zakres referencyjny. Nie jest to diagnoza, nigdy nie jest wywnioskowane z pomiaru, a aplikacja nie podejmuje na tej podstawie działań klinicznych. O to, jakie cele dotyczą ciebie, zapytaj swojego lekarza.",
"error": "Nie udało się zapisać ustawienia."
}
},
"admin": {
@@ -7471,7 +7479,7 @@
"referenceLabel": "Norma",
"readingsCount": "{count} pomiarów",
"deleteConfirmTitle": "Usunąć wynik laboratoryjny?",
"deleteConfirmDescription": "Ten wynik laboratoryjny zostanie trwale usunięty.",
"deleteConfirmDescription": "Ten wynik laboratoryjny zostanie usunięty. Zaraz po usunięciu możesz to przez chwilę cofnąć.",
"deletedToast": "Wynik usunięty",
"deleteError": "Nie udało się usunąć wyniku",
"loadError": "Nie udało się wczytać wyników",
@@ -7950,7 +7958,7 @@
},
"deleteConfirm": {
"title": "Usunąć ten epizod?",
"body": "„{label}” oraz wszystkie powiązane wpisy dzienne zostaną usunięte. Tej operacji nie można cofnąć."
"body": "„{label}” oraz wszystkie powiązane wpisy dzienne zostaną usunięte. Zaraz po usunięciu możesz to przez chwilę cofnąć."
},
"vital": {
"RESTING_HEART_RATE": "Spoczynkowe tętno",
@@ -0,0 +1,219 @@
/**
* Copy-matches-behaviour guard.
*
* Three classes of copy that drifted away from the code they describe, each
* pinned here so the pair fails loudly if either half moves again:
*
* 1. DELETE CONFIRMATIONS. A confirmation is the moment a user weighs the
* consequence. Four dialogs promised permanence on surfaces that render
* an Undo affordance (measurements bulk, mood bulk, labs, illness) — a
* cautious user declines a safe delete, and the warning loses weight
* where it is true. This test pins both directions: undo-able surfaces
* must offer the undo sentence and must NOT claim finality; the
* genuinely permanent ones must keep their warning.
*
* 2. SLEEP DEBT. v1.19.0 replaced the summed shortfall with a running
* balance that credits surplus sleep. The labels still described the old
* arithmetic, so the word contradicted the number for anyone who caught
* up after a short night.
*
* 3. GLUCOSE REFERENCE BAND. The declared diabetes opt-in has a server
* preference and a route; the copy for its web control must exist in
* every locale, since a missing key silently degrades to the raw key on
* a surface that makes a clinical-adjacent claim.
*
* Reversibility evidence for the undo-able set:
* - measurements: `POST /api/measurements/restore`, undo toast in
* `src/components/measurements/measurement-list.tsx`
* - mood: `POST /api/mood-entries/restore`, undo toast in
* `src/components/mood/mood-list.tsx`
* - labs: `POST /api/labs/restore`, undo toast in
* `src/components/labs/lab-history-list.tsx`
* - illness: `POST /api/illness/episodes/[id]/restore`, undo toast in
* `src/components/illness/episode-menu.tsx`
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const MESSAGES = join(__dirname, "../../messages");
const LOCALES = ["de", "en", "es", "fr", "it", "pl"] as const;
function bundle(locale: string): Record<string, unknown> {
return JSON.parse(readFileSync(join(MESSAGES, `${locale}.json`), "utf8"));
}
function resolve(obj: Record<string, unknown>, path: string): string {
let cur: unknown = obj;
for (const seg of path.split(".")) {
expect(cur, `${path} is missing at "${seg}"`).toBeTypeOf("object");
cur = (cur as Record<string, unknown>)[seg];
}
expect(typeof cur, `${path} must resolve to a string`).toBe("string");
return cur as string;
}
/**
* Per-locale phrases that assert a deletion cannot be taken back. A dialog on
* an undo-able surface must contain none of these.
*/
const FINALITY_PHRASES: Record<string, readonly string[]> = {
en: ["cannot be undone", "can't be undone", "permanently", "permanent"],
de: ["nicht rückgängig", "endgültig", "dauerhaft", "unwiderruflich"],
es: ["no se puede deshacer", "permanente", "irreversible"],
fr: [
"irréversible",
"définitivement",
"définitive",
"ne peut pas être annulé",
],
it: [
"non può essere annullata",
"definitivamente",
"definitiva",
"permanente",
],
pl: ["nie można cofnąć", "trwale", "nieodwracaln"],
};
/** Per-locale phrase that promises the short undo window. */
const UNDO_PHRASES: Record<string, string> = {
en: "undo",
de: "rückgängig",
es: "deshacerlo",
fr: "annuler cette action",
it: "annullare",
pl: "cofnąć",
};
/** Confirmation bodies whose surface renders an Undo affordance. */
const UNDOABLE_CONFIRM_KEYS = [
"measurements.deleteConfirmDescription",
"measurements.bulkDeleteConfirmBody",
"mood.deleteConfirmDescription",
"mood.bulkDeleteConfirmBody",
"labs.deleteConfirmDescription",
"illness.deleteConfirm.body",
] as const;
/**
* Confirmation bodies whose delete really is unrecoverable for the user —
* hard delete or a tombstone with no restore path. These MUST keep a
* finality warning; softening them would be the mirror-image bug.
*/
const PERMANENT_CONFIRM_KEYS = [
// hard delete + cascade of every recorded value
"labs.biomarker.deleteConfirmDescription",
// `?purge=true` hard-deletes the tag off every past entry
"mood.manage.purgeBody",
// GDPR Art. 17 account purge
"settings.deleteAccountConfirmDescription",
] as const;
describe("delete confirmations describe what the surface actually does", () => {
for (const locale of LOCALES) {
const msgs = bundle(locale);
describe(locale, () => {
for (const key of UNDOABLE_CONFIRM_KEYS) {
it(`${key} offers undo and claims no finality`, () => {
const copy = resolve(msgs, key).toLowerCase();
for (const phrase of FINALITY_PHRASES[locale]) {
expect(
copy,
`${locale}/${key} claims finality ("${phrase}") but the surface renders an Undo`,
).not.toContain(phrase.toLowerCase());
}
expect(
copy,
`${locale}/${key} should tell the user the delete can be undone`,
).toContain(UNDO_PHRASES[locale].toLowerCase());
});
}
for (const key of PERMANENT_CONFIRM_KEYS) {
it(`${key} keeps its finality warning`, () => {
const copy = resolve(msgs, key).toLowerCase();
const warns = FINALITY_PHRASES[locale].some((p) =>
copy.includes(p.toLowerCase()),
);
expect(
warns,
`${locale}/${key} deletes irrecoverably — the copy must say so`,
).toBe(true);
});
}
});
}
});
describe("sleep debt is labelled as a balance, not a summed deficit", () => {
/**
* The figure is a running balance: a short night adds, a long night pays it
* down. Deficit/shortfall vocabulary describes the pre-v1.19.0 sum.
*/
const STALE_SUM_VOCABULARY: Record<string, readonly string[]> = {
en: ["cumulative", "shortfall", "accumulated"],
de: ["kumuliert", "defizit", "aufgelaufen"],
es: ["acumulado", "acumulada", "déficit"],
fr: ["cumulé", "cumulée", "déficit"],
it: ["cumulato", "cumulata", "accumulato", "deficit"],
pl: ["skumulowan", "niedobór", "niedoboru"],
};
for (const locale of LOCALES) {
const msgs = bundle(locale);
it(`${locale} debt captions carry no summed-deficit wording`, () => {
for (const key of [
"insights.sleep.debt.caption",
"insights.sleep.debt.clearCaption",
]) {
const copy = resolve(msgs, key).toLowerCase();
for (const term of STALE_SUM_VOCABULARY[locale]) {
expect(
copy,
`${locale}/${key} still describes the pre-v1.19.0 summed shortfall ("${term}")`,
).not.toContain(term);
}
}
});
}
it("the explainer states that catch-up sleep pays the balance down", () => {
const copy = resolve(bundle("en"), "insights.sleep.debt.computedInfo");
expect(copy.toLowerCase()).toContain("balance");
expect(copy.toLowerCase()).toContain("catch-up");
});
});
describe("the glucose reference band control is translated everywhere", () => {
const KEYS = [
"settings.glucoseReference.title",
"settings.glucoseReference.description",
"settings.glucoseReference.enable",
"settings.glucoseReference.explainer",
"settings.glucoseReference.disclaimer",
"settings.glucoseReference.error",
] as const;
for (const locale of LOCALES) {
it(`${locale} carries every key`, () => {
const msgs = bundle(locale);
for (const key of KEYS) {
expect(resolve(msgs, key).length).toBeGreaterThan(0);
}
});
}
it("the disclaimer refuses the diagnosis reading (EN)", () => {
const copy = resolve(
bundle("en"),
"settings.glucoseReference.disclaimer",
).toLowerCase();
expect(copy).toContain("not a diagnosis");
expect(copy).toContain("never inferred");
});
});
@@ -0,0 +1,82 @@
/**
* `GET /api/auth/passkeys` — the passkey management list.
*
* The management UI renders a "last used" column per credential, and a
* passkey that reads "never used" is something a person acts on: they may
* delete a working credential believing it is dead. So the field is part of
* the route's contract, not an incidental select — dropping it from the
* projection would silently turn every row into "never used" without any
* type or lint failure.
*
* The write half (the stamp on a verified assertion) is pinned in
* `src/lib/auth/__tests__/passkey.test.ts`.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/api-handler", () => ({
apiHandler: (fn: unknown) => fn,
requireAuth: vi.fn(),
}));
vi.mock("@/lib/db", () => ({
prisma: { passkey: { findMany: vi.fn() } },
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
import { GET } from "../route";
import { requireAuth } from "@/lib/api-handler";
import { prisma } from "@/lib/db";
const USER = { id: "user-1", username: "u", email: "u@example.com" };
const ROW = {
id: "pk-1",
name: "Phone",
credentialDeviceType: "multiDevice",
credentialBackedUp: true,
createdAt: new Date("2026-01-01T00:00:00Z"),
lastUsedAt: new Date("2026-07-01T10:00:00Z"),
};
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(requireAuth).mockResolvedValue({ user: USER } as never);
vi.mocked(prisma.passkey.findMany).mockResolvedValue([ROW] as never);
});
describe("GET /api/auth/passkeys", () => {
it("selects lastUsedAt, scoped to the calling user", async () => {
await (GET as unknown as () => Promise<Response>)();
expect(prisma.passkey.findMany).toHaveBeenCalledTimes(1);
const args = vi.mocked(prisma.passkey.findMany).mock.calls[0][0];
expect(args?.where).toEqual({ userId: USER.id });
expect(args?.select).toMatchObject({ lastUsedAt: true });
});
it("emits lastUsedAt in the response envelope", async () => {
const res = await (GET as unknown as () => Promise<Response>)();
const body = await res.json();
expect(body.error).toBeNull();
expect(body.data).toHaveLength(1);
expect(body.data[0]).toHaveProperty("lastUsedAt");
expect(new Date(body.data[0].lastUsedAt).toISOString()).toBe(
"2026-07-01T10:00:00.000Z",
);
});
it("passes a never-used credential through as null, not as a missing key", async () => {
vi.mocked(prisma.passkey.findMany).mockResolvedValue([
{ ...ROW, lastUsedAt: null },
] as never);
const res = await (GET as unknown as () => Promise<Response>)();
const body = await res.json();
expect(Object.keys(body.data[0])).toContain("lastUsedAt");
expect(body.data[0].lastUsedAt).toBeNull();
});
});
+2 -1
View File
@@ -5,7 +5,8 @@
* signals the Sleep page + the iOS client render off the SAME canonical night
* reconstruction the Sleep Score uses:
*
* - `sleepDebt` — cumulative deficit over the rolling window, with a calm
* - `sleepDebt` — outstanding balance over the rolling window (surplus
* sleep pays it down), with a calm
* `partial` state under the night threshold.
* - `chronotype` — MCTQ MSF / MSFsc band + social jetlag, with a `learning`
* state until enough free-day nights exist.
@@ -22,8 +22,11 @@ function hoursMinutes(totalMinutes: number): {
/**
* v1.17.0 — sleep-debt headline.
*
* The cumulative shortfall between sleep need and sleep obtained over the
* rolling window. Under the night threshold it shows the calm `partial`
* The outstanding balance between sleep need and sleep obtained over the
* rolling window — a night below need adds to it, a night above need pays it
* down (v1.19.0 replaced the summed shortfall with this running balance; the
* copy follows the arithmetic). Under the night threshold it shows the calm
* `partial`
* state ("still learning — N more nights") and never asserts a total off thin
* data. Mobile-first: a single readable figure + one line of grounded context.
*/
@@ -267,6 +267,21 @@ describe("settings sections — SSR smoke", () => {
expect(html).not.toContain("settings.sections.thresholds.");
});
it("<ThresholdsSection> carries the glucose reference band control", () => {
// The declared diabetes opt-in (`PATCH /api/auth/me/diabetes`) selects the
// tighter glucose band and grounds the Coach, but shipped with no web
// control at all — a web-only account could never reach it. It belongs on
// Targets: same question ("which range judges my reading") as the editor
// below it, and the copy must stay honest about being a reference band
// rather than a diagnosis.
const html = renderFramed("thresholds", <ThresholdsSection />);
expect(html).toContain("Glucose reference band");
expect(html).toContain("Use the diabetes goal band");
expect(html).toContain("not a diagnosis");
expect(html).toContain('data-slot="glucose-reference-disclaimer"');
expect(html).not.toContain("settings.glucoseReference.");
});
it("<ThresholdsSection> resolves the German title", () => {
const html = renderFramed("thresholds", <ThresholdsSection />, "de");
// v1.8.7.1 — German title is "Zielwerte" (Targets only).
@@ -0,0 +1,110 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Droplet, Info } from "lucide-react";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet, apiPatch } from "@/lib/api/api-fetch";
/**
* Glucose reference band — the web control for the declared diabetes opt-in
* (`GET/PATCH /api/auth/me/diabetes`).
*
* WHY IT LIVES ON TARGETS
* -----------------------
* The flag answers exactly the question this page answers — "which range is my
* reading judged against" — and the page already owns the four glucose target
* rows. Putting it anywhere else would split one concept across two surfaces.
* The card sits ABOVE the per-metric editor because the two controls are
* ordered: this one picks the default band, a per-metric override below then
* beats it (`src/lib/targets/glucose-targets.ts`).
*
* WHAT IT DOES NOT DO
* -------------------
* The flag is a display preference, never a diagnosis, and is never inferred
* from a reading. It selects the band the glucose surfaces and the Coach's
* reference grounding read; the app takes no clinical action on it. The copy
* says so at the point of the switch rather than in a help page.
*
* Note: `GET /api/user/thresholds` (the editor below) reports the general
* bands regardless of this flag — the flag resolves in the targets pipeline
* (`src/lib/targets/build-response.ts`), which is why the mutation invalidates
* `insightsTargets()` rather than `userThresholds()`.
*/
interface DiabetesPrefShape {
hasDiabetes: boolean;
}
export function GlucoseReferenceCard() {
const { t } = useTranslations();
const queryClient = useQueryClient();
const { data } = useQuery({
queryKey: queryKeys.diabetesPref(),
queryFn: async () => apiGet<DiabetesPrefShape>("/api/auth/me/diabetes"),
});
const toggle = useMutation({
mutationFn: async (hasDiabetes: boolean) =>
apiPatch<DiabetesPrefShape>("/api/auth/me/diabetes", { hasDiabetes }),
onSuccess: (next) => {
queryClient.setQueryData(queryKeys.diabetesPref(), next);
void queryClient.invalidateQueries({
queryKey: queryKeys.insightsTargets(),
});
},
});
const enabled = data?.hasDiabetes ?? false;
return (
<SettingsCard>
<SettingsCardHeader
icon={Droplet}
title={t("settings.glucoseReference.title")}
description={t("settings.glucoseReference.description")}
/>
<div className="mt-4 space-y-4 pl-7">
<div className="flex items-start justify-between gap-4">
<Label htmlFor="glucose-reference-diabetes" className="text-sm">
{t("settings.glucoseReference.enable")}
</Label>
<Switch
id="glucose-reference-diabetes"
checked={enabled}
disabled={toggle.isPending || data == null}
onCheckedChange={(v) => toggle.mutate(v)}
className="mt-0.5 shrink-0"
/>
</div>
<p className="text-sm">{t("settings.glucoseReference.explainer")}</p>
<div
data-slot="glucose-reference-disclaimer"
className="border-border bg-muted/40 flex items-start gap-2.5 rounded-lg border-l-2 p-3"
>
<Info
className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0"
aria-hidden="true"
/>
<p className="text-foreground/80 text-xs leading-relaxed">
{t("settings.glucoseReference.disclaimer")}
</p>
</div>
{toggle.isError ? (
<p className="text-destructive text-sm" role="alert">
{t("settings.glucoseReference.error")}
</p>
) : null}
</div>
</SettingsCard>
);
}
+11 -2
View File
@@ -1,5 +1,6 @@
"use client";
import { GlucoseReferenceCard } from "@/components/settings/glucose-reference-card";
import { ThresholdsEditorSection } from "@/components/settings/thresholds-editor-section";
/**
@@ -31,6 +32,14 @@ import { ThresholdsEditorSection } from "@/components/settings/thresholds-editor
* the names stop clashing.
*/
export function ThresholdsSection() {
// Per-metric threshold ranges; the heading is supplied by the route frame.
return <ThresholdsEditorSection id="thresholds" />;
// The glucose reference band sits ABOVE the per-metric editor: it picks the
// DEFAULT band a glucose reading is judged against, and a per-metric
// override in the editor below then beats it. Same question, one page —
// splitting the two across surfaces would hide the precedence.
return (
<div className="space-y-4">
<GlucoseReferenceCard />
<ThresholdsEditorSection id="thresholds" />
</div>
);
}
@@ -202,7 +202,7 @@ describe("getCoachSystemPrompt — DE", () => {
// German coach can cite glucose + duration values with a valid token.
expect(prompt).toMatch(/mg\/dL/);
expect(prompt).toMatch(/mmol\/L/);
expect(prompt).toMatch(/min[\s\S]*\(Schlafdefizit/);
expect(prompt).toMatch(/min[\s\S]*\(Schlafschuld/);
});
it("carries the v1.21.2 narrative-presence clauses (A4/A5/A6/A7)", () => {
@@ -157,7 +157,7 @@ interface SleepRhythmBlockContext {
* ── v1.17.0 sleep-rhythm block (sleep-debt + chronotype) ──────────
*
* The two server-authoritative timing signals the Sleep page + the
* dashboard summary render — cumulative sleep debt and the MCTQ
* dashboard summary render — the outstanding sleep-debt balance and the MCTQ
* chronotype band + social jetlag. Built from the SAME assembler the
* dashboard route uses (`reconstructNights` → `computeSleepRhythmFromNights`),
* over the rhythm's OWN fixed trailing-42-day rows, so the coach quotes the
+4 -3
View File
@@ -361,8 +361,9 @@ ISO-week means.
timing signals — the SAME values the Sleep page and dashboard show, so
cite them as the user sees them and never recompute. It holds:
- "sleepDebt": { state, debtMinutes, needMinutes }. "debtMinutes" is
the cumulative deficit against the age-based nightly need over the
trailing window, "needMinutes" the target per night — both in
the OUTSTANDING BALANCE against the age-based nightly need over the
trailing window — a short night adds to it, a long night pays it
down, and it floors at 0 — "needMinutes" the target per night — both in
MINUTES (convert to hours/minutes when you speak, e.g. 320 → "5h
20m of sleep debt"). When "state" is "partial" the window is still
short: treat the figure as still calibrating, not a firm number.
@@ -980,7 +981,7 @@ Regeln:
- "value" ist ein vorformatierter Anzeigestring ("138/85", "84,2",
"4,1"). Stimmung als N/5 formatieren, wenn numerisch.
- "unit" ist eines aus mmHg, kg, bpm, /5, %, mg/dL, mmol/L, min
(Schlafdefizit / sozialer Jetlag).
(Schlafschuld / sozialer Jetlag).
- "window" ist eines aus last7days, last30days, last90days, allTime;
bei Tages-Pins weglassen.
- Lass den ganzen Block weg (KEINE ---KEYVALUES---Zeile, KEINE
+40
View File
@@ -137,3 +137,43 @@ describe("verifyAuthentication — Zod boundary (L-3)", () => {
expect(out.verification.verified).toBe(true);
});
});
/**
* "Last used" is a security-page fact people act on: a passkey that reads
* "never used" invites deletion. The column is only ever written here, on a
* verified assertion, so the stamp and the counter bump must stay one write.
* The read half is pinned in
* `src/app/api/auth/passkeys/__tests__/route.test.ts`.
*/
describe("verifyAuthentication — last-used stamp", () => {
// The file-level `beforeEach` re-arms the mocks but does not clear their
// call history, so the write assertions below need a clean ledger.
beforeEach(() => {
vi.mocked(prisma.passkey.update).mockClear();
});
it("stamps lastUsedAt alongside the counter on a verified assertion", async () => {
const before = Date.now();
await verifyAuthentication("ch-1", VALID_BODY);
expect(prisma.passkey.update).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.passkey.update).mock.calls[0][0];
expect(call.where).toEqual({ id: "pk-1" });
expect(call.data?.counter).toBe(BigInt(1));
const stamped = call.data?.lastUsedAt;
expect(stamped).toBeInstanceOf(Date);
expect((stamped as Date).getTime()).toBeGreaterThanOrEqual(before);
});
it("does not stamp lastUsedAt when the assertion fails", async () => {
vi.mocked(verifyAuthenticationResponse).mockResolvedValue({
verified: false,
} as never);
await verifyAuthentication("ch-1", VALID_BODY);
expect(prisma.passkey.update).not.toHaveBeenCalled();
});
});
+2 -1
View File
@@ -4,7 +4,8 @@
* The one place that turns a user's raw `SLEEP_DURATION` rows into the two
* timing signals the Sleep page + the iOS client + the dashboard render:
*
* - cumulative SLEEP DEBT over the rolling window (`computeSleepDebt`), and
* - the outstanding SLEEP DEBT balance over the rolling window
* (`computeSleepDebt`; surplus sleep pays it down), and
* - MCTQ CHRONOTYPE + social jetlag (`computeChronotype`).
*
* ONE ENGINE, NO DUPLICATION
+6 -4
View File
@@ -417,13 +417,15 @@ const sleepDebtResource = z.object({
state: z
.enum(["partial", "ready"])
.describe(
"`partial` until enough tracked nights exist (calm 'still learning'); `ready` once the rolling window can assert a cumulative debt.",
"`partial` until enough tracked nights exist (calm 'still learning'); `ready` once the rolling window can assert a balance.",
),
debtMinutes: z
.number()
.int()
.nonnegative()
.describe("Cumulative sleep debt over the window, in minutes, after caps."),
.describe(
"Outstanding sleep-debt balance over the window, in minutes, after caps. A night below need adds its shortfall; a night above need pays the balance down (floored at 0).",
),
needMinutes: z
.number()
.int()
@@ -513,7 +515,7 @@ const sleepRhythmResponse = z
.meta({
id: "SleepRhythmResource",
description:
"Server-authoritative sleep-rhythm read: cumulative sleep debt over the rolling window + MCTQ chronotype (MSF/MSFsc band, social jetlag). Free vs work nights default to weekend = free in the user's timezone (no work calendar). A view over existing per-stage SLEEP_DURATION rows — no schema, no new type.",
"Server-authoritative sleep-rhythm read: the outstanding sleep-debt balance over the rolling window + MCTQ chronotype (MSF/MSFsc band, social jetlag). Free vs work nights default to weekend = free in the user's timezone (no work calendar). A view over existing per-stage SLEEP_DURATION rows — no schema, no new type.",
});
// ── Time-series adapter (iOS chart source) ───────────────────────────
@@ -1004,7 +1006,7 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Measurements"],
summary: "Sleep rhythm — sleep-debt + chronotype (v1.17.0)",
description:
"Returns the two server-authoritative sleep-timing signals the Sleep page + iOS render off the same canonical night reconstruction the Sleep Score uses: cumulative `sleepDebt` over the rolling window (calm `partial` state under the night threshold) and MCTQ `chronotype` (MSF/MSFsc band + social jetlag, `learning` state until enough free-day nights exist). Free vs work nights default to weekend = free in the user's timezone (no work calendar). A read-only view over existing per-stage SLEEP_DURATION rows — no schema, no new measurement type.",
"Returns the two server-authoritative sleep-timing signals the Sleep page + iOS render off the same canonical night reconstruction the Sleep Score uses: the outstanding `sleepDebt` balance over the rolling window (calm `partial` state under the night threshold) and MCTQ `chronotype` (MSF/MSFsc band + social jetlag, `learning` state until enough free-day nights exist). Free vs work nights default to weekend = free in the user's timezone (no work calendar). A read-only view over existing per-stage SLEEP_DURATION rows — no schema, no new measurement type.",
responses: {
"200": {
description: "Sleep-debt + chronotype DTO.",
+8
View File
@@ -21,6 +21,14 @@ export const settingsKeys = {
/** v1.8.5 — user-level injection-site preferences (global exclusion). */
injectionSitePrefs: () => ["settings", "injection-site-prefs"] as const,
/**
* The declared diabetes opt-in behind `GET/PATCH /api/auth/me/diabetes`.
* Selects which reference band glucose readings are judged against, so the
* toggle also invalidates `insightsTargets()` — the resolved band rides
* on that payload.
*/
diabetesPref: () => ["settings", "diabetes"] as const,
/**
* v1.18.0 — the per-user module enable/disable map behind
* `GET/PATCH /api/auth/me/modules`. Used as the Module-hub mutation key;