diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5b27a7b90..9af17af71 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,41 @@
## [Unreleased]
+## [1.30.34] — 2026-07-19
+
+- **Rearranging your dashboard no longer wipes the comparison baseline.**
+ A layout save from a client that does not know about that setting — the
+ app never sends it — silently reset it to "none". Every field of the
+ layout now has to declare whether an absent value means "clear it" or
+ "leave it alone", so the next field added cannot repeat this.
+- **Four clinical widgets stopped disappearing from a saved layout.**
+ Pain, grip strength, waist and waist-to-height were dropped by the save
+ as unknown, so a placed widget was lost every time.
+- **Mood and BMI reach the app's home screen.** Both existed in the
+ record but were absent from the summary the app reads, so those two
+ tiles could not be shown while every other one worked.
+- **A pen carries its manufacturer and printed strength.** Without them,
+ containers entered on the web could not be shown in the app's pen list.
+- **Destructive controls ask first.** Signing every other device out,
+ revoking a session, and revoking a trusted device fired on a single
+ tap. A sweep found two more with neither a confirmation nor an undo: a
+ Coach reminder and a travel entry. Deleting a document still does not
+ ask — it can be undone, which covers the mis-tap.
+- **Delete confirmations stop overclaiming.** Four of them promised the
+ deletion could not be undone while the same screen offered an undo.
+ Warnings that are wrong make the ones that are right easier to ignore.
+- **The sleep-debt figure is described correctly again.** The calculation
+ changed to a running balance that credits surplus in v1.19.0; the label
+ still said "cumulative shortfall". The wrong description had spread to
+ the Coach, which was reasoning about the number on the old definition,
+ and to the published API description.
+- **The glucose reference band can be chosen on the web.** The setting
+ already worked and already fed the Coach — nothing on the web set it.
+ It selects a reference range; it is not a diagnosis and is never
+ inferred from a reading.
+
+One migration (0256), additive. No breaking changes.
+
## [1.30.33] — 2026-07-19
Security release. It ships on its own because it changes how a browser
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 1b8fc1dc1..1adc19f7a 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
- version: 1.30.33
+ version: 1.30.34
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -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.
@@ -12425,6 +12425,19 @@ components:
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- type: "null"
+ manufacturer:
+ description: Marketing-authorisation holder / maker as printed on the carton. Free text, max 120 chars.
+ anyOf:
+ - type: string
+ maxLength: 120
+ - type: "null"
+ doseStrength:
+ description: Strength as printed on the container, e.g. "5 mg/0.5 ml". Free text, max 60 chars — deliberately not split
+ into a number + unit because pens state strength per dose, per ml, or per cartridge.
+ anyOf:
+ - type: string
+ maxLength: 60
+ - type: "null"
notes:
anyOf:
- type: string
@@ -12458,6 +12471,19 @@ components:
type: number
minimum: 0
maximum: 1000
+ manufacturer:
+ description: Marketing-authorisation holder / maker as printed on the carton. Free text, max 120 chars.
+ anyOf:
+ - type: string
+ maxLength: 120
+ - type: "null"
+ doseStrength:
+ description: Strength as printed on the container, e.g. "5 mg/0.5 ml". Free text, max 60 chars — deliberately not split
+ into a number + unit because pens state strength per dose, per ml, or per cartridge.
+ anyOf:
+ - type: string
+ maxLength: 60
+ - type: "null"
notes:
anyOf:
- type: string
@@ -17930,12 +17956,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 +18103,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:
@@ -19420,6 +19447,18 @@ components:
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- type: "null"
+ manufacturer:
+ anyOf:
+ - type: string
+ - type: "null"
+ description: Marketing-authorisation holder / maker as printed on the carton. NULL for a container registered before the
+ field existed, and for plain supply rows that never carried one.
+ doseStrength:
+ anyOf:
+ - type: string
+ - type: "null"
+ description: Strength as printed on the container, e.g. "5 mg/0.5 ml". Free text, not split into a number + unit — pens
+ state strength per dose, per ml, or per cartridge. NULL when unknown.
notes:
anyOf:
- type: string
@@ -19444,6 +19483,8 @@ components:
- expiresAt
- printedExpiry
- purchasedAt
+ - manufacturer
+ - doseStrength
- notes
- createdAt
- updatedAt
diff --git a/messages/de.json b/messages/de.json
index ad9268086..7bbc97d04 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -425,7 +425,9 @@
"steps": "Schritte",
"totalBodyWater": "Gesamtkörperwasser",
"boneMass": "Knochenmasse",
- "oxygenSaturation": "Sauerstoffsättigung"
+ "oxygenSaturation": "Sauerstoffsättigung",
+ "mood": "Stimmung",
+ "bmi": "BMI"
},
"unit": {
"weight": "kg",
@@ -437,7 +439,9 @@
"steps": "Schritte",
"totalBodyWater": "kg",
"boneMass": "kg",
- "oxygenSaturation": "%"
+ "oxygenSaturation": "%",
+ "mood": "/5",
+ "bmi": "kg/m²"
}
}
},
@@ -578,7 +582,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 +730,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": {
@@ -1683,7 +1687,10 @@
"packagingDefaultPackLabel": "Standard-Packungsgröße (Einheiten)",
"packagingDefaultPackHelper": "Vorbefüllung beim Erfassen neuer Packungen (1–1000). Leer lassen, wenn keine üblich ist.",
"packagingSuccess": "Packungsangaben gespeichert",
- "packagingFailed": "Speichern fehlgeschlagen"
+ "packagingFailed": "Speichern fehlgeschlagen",
+ "addManufacturerLabel": "Hersteller",
+ "addDoseStrengthLabel": "Wirkstärke",
+ "addDoseStrengthHint": "Wie auf dem Behälter aufgedruckt, z. B. 5 mg/0,5 ml."
},
"erweitert": {
"group": {
@@ -2862,9 +2869,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."
},
@@ -4672,7 +4679,9 @@
"description": "Füge einen Ort und einen Zeitraum hinzu – eine Reise oder einen früheren Abschnitt deiner Verlaufsdaten. Der Zeitraum überschreibt für seine Tage deinen Wohnort, damit das Wetter zu deinem tatsächlichen Aufenthalt passt. So korrigierst du die Vergangenheit.",
"none": "Noch keine Ortszeiträume.",
"add": "{label} hinzufügen",
- "remove": "Ortszeitraum entfernen"
+ "remove": "Ortszeitraum entfernen",
+ "removeConfirmTitle": "Diese Reise entfernen?",
+ "removeConfirmBody": "Messwerte aus diesem Zeitraum bleiben erhalten; nur der Reise-Eintrag verschwindet."
},
"backfill": {
"title": "Vergangene Tage nacherfassen",
@@ -4953,7 +4962,10 @@
"surfaced": "Angezeigt",
"done": "Erledigt",
"dismissed": "Verworfen"
- }
+ },
+ "deleteConfirmTitle": "Diese Erinnerung löschen?",
+ "deleteConfirmBody": "Sie wird nicht mehr ausgelöst. An bereits erfassten Daten ändert sich nichts.",
+ "deleteConfirmAction": "Löschen"
},
"aboutMe": {
"title": "Über mich",
@@ -5628,8 +5640,16 @@
"revokeAllDone": "{count} vertrauenswürdige(s) Gerät(e) entfernt.",
"unnamed": "Unbekanntes Gerät",
"current": "Dieses Gerät",
- "expires": "Vertraut bis {date}"
- }
+ "expires": "Vertraut bis {date}",
+ "revokeConfirmTitle": "Diesem Gerät nicht mehr vertrauen?",
+ "revokeConfirmBody": "Es fragt beim nächsten Anmelden wieder nach deinem zweiten Faktor.",
+ "revokeAllConfirmTitle": "Keinem Gerät mehr vertrauen?",
+ "revokeAllConfirmBody": "Jedes Gerät fragt beim nächsten Anmelden wieder nach deinem zweiten Faktor, auch dieses."
+ },
+ "revokeSessionConfirmTitle": "Dieses Gerät abmelden?",
+ "revokeSessionConfirmBody": "{device} muss sich neu anmelden. An den dort erfassten Daten ändert sich nichts.",
+ "signOutEverywhereConfirmTitle": "Alle anderen Geräte abmelden?",
+ "signOutEverywhereConfirmBody": "Jedes Gerät außer diesem muss sich neu anmelden. Dieses Gerät bleibt angemeldet. An deinen erfassten Daten ändert sich nichts."
},
"privacy": {
"intro": {
@@ -5733,6 +5753,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 +7499,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 +7978,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",
diff --git a/messages/en.json b/messages/en.json
index c78f6c78c..2995936fd 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -425,7 +425,9 @@
"steps": "Steps",
"totalBodyWater": "Total Body Water",
"boneMass": "Bone Mass",
- "oxygenSaturation": "Oxygen Saturation"
+ "oxygenSaturation": "Oxygen Saturation",
+ "mood": "Mood",
+ "bmi": "BMI"
},
"unit": {
"weight": "kg",
@@ -437,7 +439,9 @@
"steps": "steps",
"totalBodyWater": "kg",
"boneMass": "kg",
- "oxygenSaturation": "%"
+ "oxygenSaturation": "%",
+ "mood": "/5",
+ "bmi": "kg/m²"
}
}
},
@@ -578,7 +582,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 +730,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": {
@@ -1683,7 +1687,10 @@
"packagingDefaultPackLabel": "Default pack size (units)",
"packagingDefaultPackHelper": "Prefills the quantity when registering new packs (1–1000). Leave empty if none applies.",
"packagingSuccess": "Packaging details saved",
- "packagingFailed": "Saving failed"
+ "packagingFailed": "Saving failed",
+ "addManufacturerLabel": "Manufacturer",
+ "addDoseStrengthLabel": "Strength",
+ "addDoseStrengthHint": "As printed on the container, e.g. 5 mg/0.5 ml."
},
"erweitert": {
"group": {
@@ -2862,9 +2869,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."
},
@@ -4672,7 +4679,9 @@
"description": "Add a place and a date range — a trip, or a stretch of earlier history. The period overrides your home for the days it covers, so weather matches where you actually were. This is how you correct the past.",
"none": "No location periods yet.",
"add": "Add {label}",
- "remove": "Remove location period"
+ "remove": "Remove location period",
+ "removeConfirmTitle": "Remove this trip?",
+ "removeConfirmBody": "Readings taken during it stay in your record; only the trip entry goes."
},
"backfill": {
"title": "Backfill past days",
@@ -4953,7 +4962,10 @@
"surfaced": "Surfaced",
"done": "Done",
"dismissed": "Dismissed"
- }
+ },
+ "deleteConfirmTitle": "Delete this reminder?",
+ "deleteConfirmBody": "It stops being raised. Anything you have already recorded is untouched.",
+ "deleteConfirmAction": "Delete"
},
"aboutMe": {
"title": "About me",
@@ -5628,8 +5640,16 @@
"revokeAllDone": "Forgot {count} trusted device(s).",
"unnamed": "Unknown device",
"current": "This device",
- "expires": "Trusted until {date}"
- }
+ "expires": "Trusted until {date}",
+ "revokeConfirmTitle": "Stop trusting this device?",
+ "revokeConfirmBody": "It will ask for your second factor again the next time you sign in from it.",
+ "revokeAllConfirmTitle": "Stop trusting every device?",
+ "revokeAllConfirmBody": "Every device will ask for your second factor again the next time you sign in from it, including this one."
+ },
+ "revokeSessionConfirmTitle": "Sign this device out?",
+ "revokeSessionConfirmBody": "{device} will need to sign in again. Nothing recorded on it is affected.",
+ "signOutEverywhereConfirmTitle": "Sign out every other device?",
+ "signOutEverywhereConfirmBody": "Every device except this one will need to sign in again. This device stays signed in. Nothing you have recorded is affected."
},
"privacy": {
"intro": {
@@ -5733,6 +5753,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 +7499,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 +7978,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",
diff --git a/messages/es.json b/messages/es.json
index 7e77e5ed2..e3934d442 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -425,7 +425,9 @@
"steps": "Pasos",
"totalBodyWater": "Agua corporal total",
"boneMass": "Masa ósea",
- "oxygenSaturation": "Saturación de oxígeno"
+ "oxygenSaturation": "Saturación de oxígeno",
+ "mood": "Estado de ánimo",
+ "bmi": "IMC"
},
"unit": {
"weight": "kg",
@@ -437,7 +439,9 @@
"steps": "pasos",
"totalBodyWater": "kg",
"boneMass": "kg",
- "oxygenSaturation": "%"
+ "oxygenSaturation": "%",
+ "mood": "/5",
+ "bmi": "kg/m²"
}
}
},
@@ -578,7 +582,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 +730,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": {
@@ -1683,7 +1687,10 @@
"packagingDefaultPackLabel": "Tamaño de envase predeterminado (unidades)",
"packagingDefaultPackHelper": "Rellena la cantidad al registrar envases nuevos (1–1000). Déjalo vacío si no aplica.",
"packagingSuccess": "Datos del envase guardados",
- "packagingFailed": "Error al guardar"
+ "packagingFailed": "Error al guardar",
+ "addManufacturerLabel": "Fabricante",
+ "addDoseStrengthLabel": "Concentración",
+ "addDoseStrengthHint": "Tal como aparece impreso en el envase, p. ej. 5 mg/0,5 ml."
},
"erweitert": {
"group": {
@@ -2862,9 +2869,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."
},
@@ -4672,7 +4679,9 @@
"description": "Añade un lugar y un rango de fechas: un viaje o un tramo de tu historial anterior. El periodo anula tu hogar en los días que cubre, para que el tiempo coincida con donde estuviste realmente. Así corriges el pasado.",
"none": "Aún no hay periodos de ubicación.",
"add": "Añadir {label}",
- "remove": "Eliminar periodo de ubicación"
+ "remove": "Eliminar periodo de ubicación",
+ "removeConfirmTitle": "¿Quitar este viaje?",
+ "removeConfirmBody": "Las mediciones de ese periodo permanecen; solo desaparece la entrada del viaje."
},
"backfill": {
"title": "Rellenar días pasados",
@@ -4953,7 +4962,10 @@
"surfaced": "Mostrado",
"done": "Hecho",
"dismissed": "Descartado"
- }
+ },
+ "deleteConfirmTitle": "¿Eliminar este recordatorio?",
+ "deleteConfirmBody": "Deja de aparecer. Lo que ya has registrado no cambia.",
+ "deleteConfirmAction": "Eliminar"
},
"aboutMe": {
"title": "Sobre mí",
@@ -5628,8 +5640,16 @@
"revokeAllDone": "Se olvidaron {count} dispositivo(s) de confianza.",
"unnamed": "Dispositivo desconocido",
"current": "Este dispositivo",
- "expires": "De confianza hasta el {date}"
- }
+ "expires": "De confianza hasta el {date}",
+ "revokeConfirmTitle": "¿Dejar de confiar en este dispositivo?",
+ "revokeConfirmBody": "Volverá a pedir tu segundo factor la próxima vez que inicies sesión desde él.",
+ "revokeAllConfirmTitle": "¿Dejar de confiar en todos los dispositivos?",
+ "revokeAllConfirmBody": "Todos los dispositivos volverán a pedir tu segundo factor la próxima vez que inicies sesión, incluido este."
+ },
+ "revokeSessionConfirmTitle": "¿Cerrar la sesión de este dispositivo?",
+ "revokeSessionConfirmBody": "{device} tendrá que iniciar sesión de nuevo. Lo registrado en él no se ve afectado.",
+ "signOutEverywhereConfirmTitle": "¿Cerrar la sesión en los demás dispositivos?",
+ "signOutEverywhereConfirmBody": "Todos los dispositivos salvo este tendrán que iniciar sesión de nuevo. Este seguirá con la sesión abierta. Nada de lo que has registrado se ve afectado."
},
"privacy": {
"intro": {
@@ -5733,6 +5753,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 +7499,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 +7978,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",
diff --git a/messages/fr.json b/messages/fr.json
index a390a4c5f..9c57bb085 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -425,7 +425,9 @@
"steps": "Pas",
"totalBodyWater": "Eau corporelle totale",
"boneMass": "Masse osseuse",
- "oxygenSaturation": "Saturation en oxygène"
+ "oxygenSaturation": "Saturation en oxygène",
+ "mood": "Humeur",
+ "bmi": "IMC"
},
"unit": {
"weight": "kg",
@@ -437,7 +439,9 @@
"steps": "pas",
"totalBodyWater": "kg",
"boneMass": "kg",
- "oxygenSaturation": "%"
+ "oxygenSaturation": "%",
+ "mood": "/5",
+ "bmi": "kg/m²"
}
}
},
@@ -578,7 +582,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 +730,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": {
@@ -1683,7 +1687,10 @@
"packagingDefaultPackLabel": "Taille de boîte par défaut (unités)",
"packagingDefaultPackHelper": "Préremplit la quantité lors de l’enregistrement de nouvelles boîtes (1–1000). Laisse vide si aucune ne s’applique.",
"packagingSuccess": "Conditionnement enregistré",
- "packagingFailed": "Échec de l’enregistrement"
+ "packagingFailed": "Échec de l’enregistrement",
+ "addManufacturerLabel": "Fabricant",
+ "addDoseStrengthLabel": "Dosage",
+ "addDoseStrengthHint": "Tel qu'imprimé sur le contenant, par ex. 5 mg/0,5 ml."
},
"erweitert": {
"group": {
@@ -2862,9 +2869,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."
},
@@ -4672,7 +4679,9 @@
"description": "Ajoutez un lieu et une plage de dates : un voyage ou une portion de votre historique passé. La période remplace votre domicile pour les jours qu'elle couvre, afin que la météo corresponde à l'endroit où vous étiez réellement. C'est ainsi que vous corrigez le passé.",
"none": "Aucune période de localisation pour l'instant.",
"add": "Ajouter {label}",
- "remove": "Supprimer la période de localisation"
+ "remove": "Supprimer la période de localisation",
+ "removeConfirmTitle": "Retirer ce voyage ?",
+ "removeConfirmBody": "Les mesures de cette période restent dans ton dossier ; seule l'entrée du voyage disparaît."
},
"backfill": {
"title": "Récupérer les jours passés",
@@ -4953,7 +4962,10 @@
"surfaced": "Affiché",
"done": "Terminé",
"dismissed": "Ignoré"
- }
+ },
+ "deleteConfirmTitle": "Supprimer ce rappel ?",
+ "deleteConfirmBody": "Il ne se déclenchera plus. Ce que tu as déjà enregistré reste intact.",
+ "deleteConfirmAction": "Supprimer"
},
"aboutMe": {
"title": "À propos de moi",
@@ -5628,8 +5640,16 @@
"revokeAllDone": "{count} appareil(s) de confiance oublié(s).",
"unnamed": "Appareil inconnu",
"current": "Cet appareil",
- "expires": "De confiance jusqu'au {date}"
- }
+ "expires": "De confiance jusqu'au {date}",
+ "revokeConfirmTitle": "Ne plus faire confiance à cet appareil ?",
+ "revokeConfirmBody": "Il redemandera ton deuxième facteur à la prochaine connexion depuis cet appareil.",
+ "revokeAllConfirmTitle": "Ne plus faire confiance à aucun appareil ?",
+ "revokeAllConfirmBody": "Chaque appareil redemandera ton deuxième facteur à la prochaine connexion, celui-ci compris."
+ },
+ "revokeSessionConfirmTitle": "Déconnecter cet appareil ?",
+ "revokeSessionConfirmBody": "{device} devra se reconnecter. Ce qui y est enregistré n'est pas affecté.",
+ "signOutEverywhereConfirmTitle": "Déconnecter tous les autres appareils ?",
+ "signOutEverywhereConfirmBody": "Chaque appareil sauf celui-ci devra se reconnecter. Celui-ci reste connecté. Rien de ce que tu as enregistré n'est affecté."
},
"privacy": {
"intro": {
@@ -5733,6 +5753,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 +7499,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 +7978,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",
diff --git a/messages/it.json b/messages/it.json
index 827c43c5f..ca31566c5 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -425,7 +425,9 @@
"steps": "Passi",
"totalBodyWater": "Acqua corporea totale",
"boneMass": "Massa ossea",
- "oxygenSaturation": "Saturazione di ossigeno"
+ "oxygenSaturation": "Saturazione di ossigeno",
+ "mood": "Umore",
+ "bmi": "IMC"
},
"unit": {
"weight": "kg",
@@ -437,7 +439,9 @@
"steps": "passi",
"totalBodyWater": "kg",
"boneMass": "kg",
- "oxygenSaturation": "%"
+ "oxygenSaturation": "%",
+ "mood": "/5",
+ "bmi": "kg/m²"
}
}
},
@@ -578,7 +582,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 +730,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": {
@@ -1683,7 +1687,10 @@
"packagingDefaultPackLabel": "Dimensione confezione predefinita (unità)",
"packagingDefaultPackHelper": "Precompila la quantità quando registri nuove confezioni (1–1000). Lascia vuoto se non applicabile.",
"packagingSuccess": "Dati confezione salvati",
- "packagingFailed": "Salvataggio non riuscito"
+ "packagingFailed": "Salvataggio non riuscito",
+ "addManufacturerLabel": "Produttore",
+ "addDoseStrengthLabel": "Dosaggio",
+ "addDoseStrengthHint": "Come stampato sul contenitore, ad es. 5 mg/0,5 ml."
},
"erweitert": {
"group": {
@@ -2862,9 +2869,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."
},
@@ -4672,7 +4679,9 @@
"description": "Aggiungi un luogo e un intervallo di date: un viaggio o un tratto della tua cronologia passata. Il periodo prevale sulla tua residenza nei giorni che copre, così il meteo corrisponde a dove ti trovavi davvero. È così che correggi il passato.",
"none": "Nessun periodo di posizione.",
"add": "Aggiungi {label}",
- "remove": "Rimuovi periodo di posizione"
+ "remove": "Rimuovi periodo di posizione",
+ "removeConfirmTitle": "Rimuovere questo viaggio?",
+ "removeConfirmBody": "Le misurazioni di quel periodo restano nel tuo archivio; sparisce solo la voce del viaggio."
},
"backfill": {
"title": "Recupera i giorni passati",
@@ -4953,7 +4962,10 @@
"surfaced": "Mostrato",
"done": "Fatto",
"dismissed": "Ignorato"
- }
+ },
+ "deleteConfirmTitle": "Eliminare questo promemoria?",
+ "deleteConfirmBody": "Non verrà più mostrato. Quanto hai già registrato resta invariato.",
+ "deleteConfirmAction": "Elimina"
},
"aboutMe": {
"title": "Su di me",
@@ -5628,8 +5640,16 @@
"revokeAllDone": "Dimenticati {count} dispositivo/i attendibile/i.",
"unnamed": "Dispositivo sconosciuto",
"current": "Questo dispositivo",
- "expires": "Attendibile fino al {date}"
- }
+ "expires": "Attendibile fino al {date}",
+ "revokeConfirmTitle": "Non fidarsi più di questo dispositivo?",
+ "revokeConfirmBody": "Al prossimo accesso da questo dispositivo chiederà di nuovo il secondo fattore.",
+ "revokeAllConfirmTitle": "Non fidarsi più di alcun dispositivo?",
+ "revokeAllConfirmBody": "Ogni dispositivo chiederà di nuovo il secondo fattore al prossimo accesso, questo compreso."
+ },
+ "revokeSessionConfirmTitle": "Disconnettere questo dispositivo?",
+ "revokeSessionConfirmBody": "{device} dovrà accedere di nuovo. Quanto registrato su di esso non viene toccato.",
+ "signOutEverywhereConfirmTitle": "Disconnettere tutti gli altri dispositivi?",
+ "signOutEverywhereConfirmBody": "Ogni dispositivo tranne questo dovrà accedere di nuovo. Questo resta connesso. Nulla di ciò che hai registrato viene toccato."
},
"privacy": {
"intro": {
@@ -5733,6 +5753,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 +7499,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 +7978,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",
diff --git a/messages/pl.json b/messages/pl.json
index a43e94b7f..2635cfae4 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -425,7 +425,9 @@
"steps": "Kroki",
"totalBodyWater": "Całkowita woda w organizmie",
"boneMass": "Masa kostna",
- "oxygenSaturation": "Saturacja tlenem"
+ "oxygenSaturation": "Saturacja tlenem",
+ "mood": "Nastrój",
+ "bmi": "BMI"
},
"unit": {
"weight": "kg",
@@ -437,7 +439,9 @@
"steps": "kroki",
"totalBodyWater": "kg",
"boneMass": "kg",
- "oxygenSaturation": "%"
+ "oxygenSaturation": "%",
+ "mood": "/5",
+ "bmi": "kg/m²"
}
}
},
@@ -578,7 +582,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 +730,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": {
@@ -1683,7 +1687,10 @@
"packagingDefaultPackLabel": "Domyślny rozmiar opakowania (jednostki)",
"packagingDefaultPackHelper": "Wstępnie wypełnia ilość przy dodawaniu nowych opakowań (1–1000). Zostaw puste, jeśli nie dotyczy.",
"packagingSuccess": "Zapisano dane opakowania",
- "packagingFailed": "Zapis nie powiódł się"
+ "packagingFailed": "Zapis nie powiódł się",
+ "addManufacturerLabel": "Producent",
+ "addDoseStrengthLabel": "Moc",
+ "addDoseStrengthHint": "Zgodnie z nadrukiem na opakowaniu, np. 5 mg/0,5 ml."
},
"erweitert": {
"group": {
@@ -2862,9 +2869,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."
},
@@ -4672,7 +4679,9 @@
"description": "Dodaj miejsce i zakres dat: podróż lub fragment wcześniejszej historii. Okres zastępuje Twój dom w dniach, które obejmuje, aby pogoda odpowiadała miejscu, w którym naprawdę byłeś. W ten sposób korygujesz przeszłość.",
"none": "Brak okresów lokalizacji.",
"add": "Dodaj {label}",
- "remove": "Usuń okres lokalizacji"
+ "remove": "Usuń okres lokalizacji",
+ "removeConfirmTitle": "Usunąć tę podróż?",
+ "removeConfirmBody": "Pomiary z tego okresu pozostają w historii; znika tylko wpis podróży."
},
"backfill": {
"title": "Uzupełnij minione dni",
@@ -4953,7 +4962,10 @@
"surfaced": "Wyświetlone",
"done": "Gotowe",
"dismissed": "Odrzucone"
- }
+ },
+ "deleteConfirmTitle": "Usunąć to przypomnienie?",
+ "deleteConfirmBody": "Przestanie się pojawiać. Zapisane wcześniej dane pozostają bez zmian.",
+ "deleteConfirmAction": "Usuń"
},
"aboutMe": {
"title": "O mnie",
@@ -5628,8 +5640,16 @@
"revokeAllDone": "Zapomniano {count} zaufane urządzenie/a.",
"unnamed": "Nieznane urządzenie",
"current": "To urządzenie",
- "expires": "Zaufane do {date}"
- }
+ "expires": "Zaufane do {date}",
+ "revokeConfirmTitle": "Przestać ufać temu urządzeniu?",
+ "revokeConfirmBody": "Przy następnym logowaniu z tego urządzenia ponownie poprosi o drugi składnik.",
+ "revokeAllConfirmTitle": "Przestać ufać wszystkim urządzeniom?",
+ "revokeAllConfirmBody": "Każde urządzenie przy następnym logowaniu ponownie poprosi o drugi składnik, łącznie z tym."
+ },
+ "revokeSessionConfirmTitle": "Wylogować to urządzenie?",
+ "revokeSessionConfirmBody": "{device} będzie musiało zalogować się ponownie. Zapisane na nim dane pozostają bez zmian.",
+ "signOutEverywhereConfirmTitle": "Wylogować wszystkie pozostałe urządzenia?",
+ "signOutEverywhereConfirmBody": "Każde urządzenie poza tym będzie musiało zalogować się ponownie. To urządzenie pozostaje zalogowane. Zapisane dane pozostają bez zmian."
},
"privacy": {
"intro": {
@@ -5733,6 +5753,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 +7499,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 +7978,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",
diff --git a/package.json b/package.json
index 413e256e8..89ff910c7 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "healthlog",
- "version": "1.30.33",
+ "version": "1.30.34",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
diff --git a/prisma/migrations/0256_medication_inventory_pen_labelling/migration.sql b/prisma/migrations/0256_medication_inventory_pen_labelling/migration.sql
new file mode 100644
index 000000000..39170c503
--- /dev/null
+++ b/prisma/migrations/0256_medication_inventory_pen_labelling/migration.sql
@@ -0,0 +1,14 @@
+-- Carton labelling for an inventory container: manufacturer + printed strength.
+--
+-- The native client's pen list uses these two as its headline and subhead, so
+-- a container created on the web could not be shown there without fabricating
+-- an empty card. Nothing on the server held them, so the client kept its own
+-- copy locally and no back-fill was possible.
+--
+-- Both are additive and nullable. Every pre-existing row is truthfully NULL:
+-- the values were never collected, and a plain supply row (a blister pack, a
+-- bottle) has no use for either. No backfill, no default, no rewrite of the
+-- table's existing contents.
+ALTER TABLE "medication_inventory_items"
+ ADD COLUMN "manufacturer" TEXT,
+ ADD COLUMN "dose_strength" TEXT;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index ffaaff10a..4bc525cca 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -2871,6 +2871,18 @@ model MedicationInventoryItem {
/// The carton-printed expiry date the user enters at creation.
printedExpiry DateTime? @map("printed_expiry")
purchasedAt DateTime? @map("purchased_at")
+ /// Marketing-authorisation holder / maker printed on the carton. Free
+ /// text: the field is a label the user reads back, not a lookup key, and
+ /// there is no authority list a self-hoster could be held to. Nullable
+ /// because every pre-existing row predates it and plain supply rows
+ /// (blisters, bottles) have no use for it.
+ manufacturer String?
+ /// Strength as printed on the container, e.g. "5 mg/0.5 ml". Free text
+ /// and deliberately NOT parsed into a number + unit: pens state strength
+ /// per dose, per ml, or per cartridge depending on the product, and a
+ /// forced numeric split would lose which of the three it was. Nullable
+ /// for the same reason as `manufacturer`.
+ doseStrength String? @map("dose_strength")
/// Free-text note, application-layer max 200 chars.
notes String?
/// v1.25 — AES-256-GCM ciphertext for `notes`. The plaintext column
@@ -3242,9 +3254,9 @@ model RefreshToken {
// the token pair the first exchange issued (refresh-token reuse-detection
// posture). 90-second TTL enforced at read; a daily sweep prunes stale rows.
model OidcNativeHandoff {
- id String @id @default(cuid())
- userId String @map("user_id")
- codeHash String @unique @map("code_hash") // hashToken(hlh_<…>)
+ id String @id @default(cuid())
+ userId String @map("user_id")
+ codeHash String @unique @map("code_hash") // hashToken(hlh_<…>)
/// The app's S256 PKCE challenge, bound at mint; the exchange verifies
/// s256(code_verifier) against it (constant-time) before consuming.
codeChallenge String @map("code_challenge")
@@ -3254,8 +3266,8 @@ model OidcNativeHandoff {
/// revokes exactly this family member (+ its paired access token).
issuedRefreshTokenHash String? @map("issued_refresh_token_hash")
createdAt DateTime @default(now()) @map("created_at")
- ipAddress String? @map("ip_address")
- userAgent String? @map("user_agent")
+ ipAddress String? @map("ip_address")
+ userAgent String? @map("user_agent")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -6334,14 +6346,14 @@ model DocumentConditionLink {
/// versioned key (no schema column), and key rotation re-tokenises from the
/// decrypted `textEncrypted`.
model DocumentContentIndex {
- id String @id @default(cuid())
- documentId String @unique @map("document_id")
- userId String @map("user_id")
+ id String @id @default(cuid())
+ documentId String @unique @map("document_id")
+ userId String @map("user_id")
/// AES-256-GCM ciphertext of the normalised extracted text (capped, first
/// ~64 KiB), stored as the `encrypt()`-string-as-UTF-8 Bytes shape shared
/// with the Coach columns. The plaintext is recoverable server-side so
/// rotation can re-tokenise; the list query never selects it.
- textEncrypted Bytes @map("text_encrypted")
+ textEncrypted Bytes @map("text_encrypted")
/// v1.27.33 (Document vault P4 — chat about a document) — AES-256-GCM
/// ciphertext of the VERBATIM extracted text (byte-capped, NOT lowercased or
/// de-accented), stored additionally alongside `textEncrypted` so a document
@@ -6350,22 +6362,22 @@ model DocumentContentIndex {
/// UTF-8 Bytes shape as every other PHI column. Nullable: rows indexed before
/// P4 carry NULL until re-indexed, and the chat route falls back to the
/// normalised `textEncrypted` when it is absent. The list query never selects it.
- verbatimTextEncrypted Bytes? @map("verbatim_text_encrypted")
+ verbatimTextEncrypted Bytes? @map("verbatim_text_encrypted")
/// Deduped HMAC-SHA256 (truncated hex) of the normalised tokens — the blind
/// index. One-way: opaque without the server-held index subkey. GIN-indexed
/// (raw SQL in the migration; Prisma cannot express a GIN index).
- searchTokens String[] @default([]) @map("search_tokens")
+ searchTokens String[] @default([]) @map("search_tokens")
/// Provenance of the indexed text: "vision" (provider transcription of the
/// stored original), "text-ocr" (browser-OCR text posted by the client),
/// "local-pdf" (server-side text-layer extraction, no provider) or "local-ocr"
/// (server-side OCR of a scanned document — deferred follow-up). Free String;
/// adding a source value needs no migration.
- source String
+ source String
/// Provider type that produced the text (diagnostic only), or null on the
/// text-ocr path.
- providerType String? @map("provider_type")
+ providerType String? @map("provider_type")
/// Tokeniser algorithm version — bump to force a re-index on a scheme change.
- tokenizerVersion String @map("tokenizer_version")
+ tokenizerVersion String @map("tokenizer_version")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -6386,23 +6398,23 @@ model DocumentContentIndex {
/// source, so the preview is metadata-free by construction. Owner-scoped
/// serve route only; cascades on delete.
model DocumentThumbnail {
- id String @id @default(cuid())
- documentId String @unique @map("document_id")
- userId String @map("user_id")
+ id String @id @default(cuid())
+ documentId String @unique @map("document_id")
+ userId String @map("user_id")
/// AES-256-GCM ciphertext of the JPEG preview, stored as the
/// `encrypt()`-string-as-UTF-8 Bytes shape (base64 of the JPEG), the same
/// codec `DocumentContentIndex.textEncrypted` uses so the standard
/// key-rotation walk covers it. A scanned medical preview is PHI — never
/// stored in the clear or logged. The list query never selects it.
- thumbnailEncrypted Bytes @map("thumbnail_encrypted")
+ thumbnailEncrypted Bytes @map("thumbnail_encrypted")
/// Decoded thumbnail dimensions (px) — diagnostic + lets a future consumer
/// size the tile without decoding the blob.
width Int
height Int
/// Plaintext byte length of the JPEG (pre-encryption), for `Content-Length`.
- byteSize Int @map("byte_size")
+ byteSize Int @map("byte_size")
/// Source MIME the preview was rendered from (diagnostic only).
- sourceMime String @map("source_mime")
+ sourceMime String @map("source_mime")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
diff --git a/public/sw.js b/public/sw.js
index a175c4fc5..5fc13aa0e 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
- /* @sw-version-fallback */ "v1.30.33";
+ /* @sw-version-fallback */ "v1.30.34";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
diff --git a/src/__tests__/copy-matches-behaviour-guard.test.ts b/src/__tests__/copy-matches-behaviour-guard.test.ts
new file mode 100644
index 000000000..5b26a9c1c
--- /dev/null
+++ b/src/__tests__/copy-matches-behaviour-guard.test.ts
@@ -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 {
+ return JSON.parse(readFileSync(join(MESSAGES, `${locale}.json`), "utf8"));
+}
+
+function resolve(obj: Record, 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)[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 = {
+ 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 = {
+ 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 = {
+ 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");
+ });
+});
diff --git a/src/__tests__/i18n-english-leak-guard.test.ts b/src/__tests__/i18n-english-leak-guard.test.ts
index 0dd958db5..ff16c934f 100644
--- a/src/__tests__/i18n-english-leak-guard.test.ts
+++ b/src/__tests__/i18n-english-leak-guard.test.ts
@@ -81,6 +81,8 @@ const LEGIT_IDENTICAL = new Set([
// Clinical abbreviations / SI units — identical across locales.
"charts.vo2Max",
"cycle.insights.crosstab.unitGlucose",
+ "dashboard.metric.unit.bmi",
+ "dashboard.metric.unit.mood",
"dashboard.metric.unit.glucose",
"dashboard.vo2Max",
"dashboard.vo2MaxShort",
diff --git a/src/app/api/auth/passkeys/__tests__/route.test.ts b/src/app/api/auth/passkeys/__tests__/route.test.ts
new file mode 100644
index 000000000..5f99d63ae
--- /dev/null
+++ b/src/app/api/auth/passkeys/__tests__/route.test.ts
@@ -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)();
+
+ 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)();
+ 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)();
+ const body = await res.json();
+
+ expect(Object.keys(body.data[0])).toContain("lastUsedAt");
+ expect(body.data[0].lastUsedAt).toBeNull();
+ });
+});
diff --git a/src/app/api/dashboard/summary/__tests__/module-gate.test.ts b/src/app/api/dashboard/summary/__tests__/module-gate.test.ts
index 577966bf1..dfb98886d 100644
--- a/src/app/api/dashboard/summary/__tests__/module-gate.test.ts
+++ b/src/app/api/dashboard/summary/__tests__/module-gate.test.ts
@@ -87,6 +87,8 @@ const ALL_KINDS = [
"totalBodyWater",
"boneMass",
"oxygenSaturation",
+ "mood",
+ "bmi",
] as const;
function card(kind: string) {
@@ -194,11 +196,28 @@ describe("GET /api/dashboard/summary — module gate", () => {
"totalBodyWater",
"boneMass",
"oxygenSaturation",
+ // BMI is derived from weight + the profile height and belongs to no
+ // module, so it rides through with the other core vitals.
+ "bmi",
]) {
expect(kinds).toContain(core);
}
});
+ it("drops the mood card when the mood module is off", async () => {
+ // Mood is not a MeasurementType, so it gates through the synthetic
+ // `MOOD_ENTRY` key rather than a measurement enum value. Without that
+ // join the card would be unmapped and silently ship past a disabled
+ // module.
+ setDisabledModules(["mood"]);
+ const kinds = await emittedKinds();
+
+ expect(kinds).not.toContain("mood");
+ expect(kinds).toContain("weight");
+ expect(kinds).toContain("bmi");
+ expect(kinds).toHaveLength(ALL_KINDS.length - 1);
+ });
+
it("gates the cached body, so a toggle takes effect on the next request", async () => {
// The analytics LRU is not evicted by a module toggle. The filter must
// therefore run on the way out, not inside the cached build.
diff --git a/src/app/api/dashboard/summary/__tests__/route.test.ts b/src/app/api/dashboard/summary/__tests__/route.test.ts
index 3bf6040e0..1005fd8ea 100644
--- a/src/app/api/dashboard/summary/__tests__/route.test.ts
+++ b/src/app/api/dashboard/summary/__tests__/route.test.ts
@@ -68,6 +68,19 @@ vi.mock("@/lib/rollups/measurement-read", () => ({
loadUserSourcePriority: vi.fn(async () => null),
}));
+// The mood card reads the canonical daily series; its own aggregation is
+// covered by `src/lib/analytics/__tests__/mood-series.test.ts`. Pinned to an
+// empty series by default so the legacy assertions keep their "no mood"
+// expectation, and overridden per test below.
+vi.mock("@/lib/analytics/mood-series", () => ({
+ buildMoodDailySeries: vi.fn(async () => ({
+ entries: [],
+ summary: null,
+ entryCount: 0,
+ source: "live" as const,
+ })),
+}));
+
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
@@ -85,6 +98,7 @@ import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
+import { buildMoodDailySeries } from "@/lib/analytics/mood-series";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -1065,3 +1079,96 @@ describe("GET /api/dashboard/summary", () => {
expect(body.data.compliance.takenToday).toBe(1);
});
});
+
+/**
+ * Mood + BMI cards.
+ *
+ * Both metrics existed in the record but the summary payload emitted
+ * neither, so the native home screen could not render those two tiles while
+ * every other tile worked. Both are resolved server-side from the canonical
+ * source — `buildMoodDailySeries` for mood, `computeBmi` for BMI — so the
+ * client consumes a finished figure instead of re-deriving one.
+ */
+describe("GET /api/dashboard/summary — mood + BMI cards", () => {
+ function cardsOf(body: unknown): Array> {
+ return (body as { data: { metrics: Array> } }).data
+ .metrics;
+ }
+
+ it("emits a mood card from the canonical daily series", async () => {
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ vi.mocked(buildMoodDailySeries).mockResolvedValue({
+ entries: [
+ { date: "2026-07-01", score: 2, samples: 1 },
+ { date: "2026-07-02", score: 3, samples: 2 },
+ { date: "2026-07-03", score: 4.5, samples: 1 },
+ ],
+ summary: null,
+ entryCount: 3,
+ source: "rollup",
+ } as never);
+
+ const res = await callGet(makeReq());
+ expect(res.status).toBe(200);
+ const mood = cardsOf(await res.json()).find((c) => c.kind === "mood");
+
+ expect(mood).toBeDefined();
+ // Headline is the newest day's score, verbatim from the series — the
+ // route must not re-average or re-round what the helper resolved.
+ expect(mood?.latestValue).toBe(4.5);
+ expect(mood?.sparkline).toEqual([2, 3, 4.5]);
+ expect(mood?.trend).toBe("up");
+ expect(mood?.allTimeCount).toBe(3);
+ expect(mood?.titleKey).toBe("dashboard.metric.title.mood");
+ expect(mood?.unitKey).toBe("dashboard.metric.unit.mood");
+ expect(mood?.lastSeenAt).toBe("2026-07-03T00:00:00.000Z");
+ });
+
+ it("omits the mood card when the account has never logged a mood", async () => {
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ const res = await callGet(makeReq());
+ expect(res.status).toBe(200);
+ expect(cardsOf(await res.json()).find((c) => c.kind === "mood")).toBe(
+ undefined,
+ );
+ });
+
+ it("emits a BMI card computed from the profile height and latest weight", async () => {
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ // 180 cm profile.
+ vi.mocked(prisma.user.findUnique).mockResolvedValue({
+ dateOfBirth: new Date("1986-01-01"),
+ gender: null,
+ heightCm: 180,
+ } as never);
+ // `computeBmi` reads recent weight rows through `measurement.findMany`.
+ // 81 kg at 1.80 m => 25.0.
+ vi.mocked(prisma.measurement.findMany).mockResolvedValue([
+ { value: 81, measuredAt: new Date("2026-07-03T08:00:00.000Z") },
+ ] as never);
+
+ const res = await callGet(makeReq());
+ expect(res.status).toBe(200);
+ const bmi = cardsOf(await res.json()).find((c) => c.kind === "bmi");
+
+ expect(bmi).toBeDefined();
+ // The canonical helper's rounding (1 dp), not a second formula.
+ expect(bmi?.latestValue).toBe(25);
+ expect(bmi?.titleKey).toBe("dashboard.metric.title.bmi");
+ expect(bmi?.unitKey).toBe("dashboard.metric.unit.bmi");
+ });
+
+ it("omits the BMI card when the profile carries no height", async () => {
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ // Default profile has `heightCm: null`; weight alone cannot yield a BMI.
+ vi.mocked(prisma.measurement.findMany).mockResolvedValue([
+ { value: 81, measuredAt: new Date("2026-07-03T08:00:00.000Z") },
+ ] as never);
+
+ const res = await callGet(makeReq());
+ expect(res.status).toBe(200);
+ expect(cardsOf(await res.json()).find((c) => c.kind === "bmi")).toBe(
+ undefined,
+ );
+ });
+});
diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts
index 00b399f98..5dd8a4195 100644
--- a/src/app/api/dashboard/summary/route.ts
+++ b/src/app/api/dashboard/summary/route.ts
@@ -74,6 +74,9 @@ import {
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import type { SleepStage } from "@/generated/prisma/client";
import { loadBaselineProfile } from "@/lib/insights/derived/baseline";
+import { computeBmi } from "@/lib/insights/derived/bmi";
+import { isDerivedOk } from "@/lib/insights/derived/types";
+import { buildMoodDailySeries } from "@/lib/analytics/mood-series";
import {
reconstructNights,
sleepNeedMinutes,
@@ -96,7 +99,9 @@ type MetricKind =
| "steps"
| "totalBodyWater"
| "boneMass"
- | "oxygenSaturation";
+ | "oxygenSaturation"
+ | "mood"
+ | "bmi";
interface MetricCard {
id: string;
@@ -196,6 +201,8 @@ const METRIC_TITLE_KEYS: Record = {
totalBodyWater: "dashboard.metric.title.totalBodyWater",
boneMass: "dashboard.metric.title.boneMass",
oxygenSaturation: "dashboard.metric.title.oxygenSaturation",
+ mood: "dashboard.metric.title.mood",
+ bmi: "dashboard.metric.title.bmi",
};
const METRIC_UNIT_KEYS: Record = {
@@ -209,6 +216,8 @@ const METRIC_UNIT_KEYS: Record = {
totalBodyWater: "dashboard.metric.unit.totalBodyWater",
boneMass: "dashboard.metric.unit.boneMass",
oxygenSaturation: "dashboard.metric.unit.oxygenSaturation",
+ mood: "dashboard.metric.unit.mood",
+ bmi: "dashboard.metric.unit.bmi",
};
/**
@@ -615,11 +624,24 @@ async function buildDashboardSummary(
// before the per-night reconstruction sums it. v1.17.0 — the baseline
// profile rides along (one read) so the sleep-rhythm block resolves the
// age-based sleep need the cumulative-debt deficit uses.
- const [sleepPriorityJson, sleepProfile] = await Promise.all([
+ const [sleepPriorityJson, sleepProfile, moodSeries] = await Promise.all([
loadUserSourcePriority(userId),
loadBaselineProfile(prisma, userId),
+ // The mood card reads the SAME canonical daily series the web mood
+ // analytics and the dashboard snapshot read (`buildMoodDailySeries`),
+ // rollup fast-path included — never a second aggregation.
+ time("moodSeries", () => buildMoodDailySeries(userId, prisma)),
]);
+ // BMI is derived, not measured: it needs the profile height loaded above,
+ // so it cannot join the round before it. `computeBmi` is the one canonical
+ // implementation (the same one the web vitals dashboard renders through
+ // the derived-metric dispatch) — the route resolves the figure and the
+ // client consumes it, rather than every client re-deriving weight / m².
+ const bmiDerived = await time("bmi", () =>
+ computeBmi(userId, sleepProfile, { now }),
+ );
+
// Per-type metadata lookup — typed Map so a metric with no readings
// at all falls through `metaForType` to the `{ allTimeCount: 0,
// lastSeenAt: null }` default. The aggregate row's `_count._all` is
@@ -954,6 +976,69 @@ async function buildDashboardSummary(
});
}
+ // Mood — the one metric on this payload that is not a `Measurement`. The
+ // daily series is the canonical `buildMoodDailySeries` output (rollup
+ // fast-path, 1..5 scale, one entry per day with an average score), the
+ // same series the web mood analytics and the dashboard snapshot render.
+ // Emitted only when the account has ever logged a mood, matching the
+ // `allTimeCount === 0` gate every optional card above uses.
+ if (moodSeries.entryCount > 0) {
+ const spark = moodSeries.entries.slice(-SPARK_DAYS).map((e) => e.score);
+ const latestEntry = moodSeries.entries[moodSeries.entries.length - 1];
+ // The series carries UTC day labels, not timestamps — the entry's day is
+ // the only instant the source actually pins, so it is emitted as the
+ // day's start rather than inventing a reading time.
+ const latestAt = latestEntry
+ ? new Date(`${latestEntry.date}T00:00:00.000Z`).toISOString()
+ : null;
+ metrics.push({
+ id: "mood",
+ kind: "mood",
+ titleKey: METRIC_TITLE_KEYS.mood,
+ latestValue: latestEntry?.score ?? null,
+ secondaryValue: null,
+ unitKey: METRIC_UNIT_KEYS.mood,
+ unit: null,
+ sleepStages: null,
+ sleepSourceDiscrepancy: null,
+ trend: trendOf(spark),
+ sparkline: spark,
+ updatedAt: latestAt,
+ allTimeCount: moodSeries.entryCount,
+ lastSeenAt: latestAt,
+ });
+ }
+
+ // BMI — derived from the latest weight at the profile height by
+ // `computeBmi`, the single implementation the web vitals dashboard also
+ // renders. The card is omitted when the derivation is insufficient
+ // (no height on the profile, or no weight inside its window): the card
+ // shape has no "unknown" state to express honestly, and a card with a
+ // null value would read as a logging gap rather than a missing profile
+ // field. Clients that want to prompt for a height should do so from the
+ // profile surface, not from an empty dashboard tile.
+ if (isDerivedOk(bmiDerived)) {
+ const spark = bmiDerived.value.series.slice(-SPARK_DAYS);
+ metrics.push({
+ id: "bmi",
+ kind: "bmi",
+ titleKey: METRIC_TITLE_KEYS.bmi,
+ latestValue: bmiDerived.value.bmi,
+ secondaryValue: null,
+ unitKey: METRIC_UNIT_KEYS.bmi,
+ unit: null,
+ sleepStages: null,
+ sleepSourceDiscrepancy: null,
+ trend: trendOf(spark),
+ sparkline: spark,
+ // BMI moves exactly when weight does, so the weight metadata is the
+ // honest recency signal — there is no separate BMI reading.
+ updatedAt: metaForType("WEIGHT").lastSeenAt,
+ allTimeCount: metaForType("WEIGHT").allTimeCount,
+ lastSeenAt: metaForType("WEIGHT").lastSeenAt,
+ });
+ }
+
// Shared builder carries the exact tally semantics this tile always
// had (scheduled = every today-window row; taken = `takenAt` set and
// not skipped). The wire shape of `compliance` is locked for iOS.
diff --git a/src/app/api/dashboard/widgets/__tests__/route.test.ts b/src/app/api/dashboard/widgets/__tests__/route.test.ts
index 53e9bc085..5611f3fc3 100644
--- a/src/app/api/dashboard/widgets/__tests__/route.test.ts
+++ b/src/app/api/dashboard/widgets/__tests__/route.test.ts
@@ -517,3 +517,122 @@ describe("dashboard widgets — 27-id catalogue round-trip (v1.7.0 W1)", () => {
);
});
});
+
+describe("dashboard widgets — preserve-when-absent on PUT", () => {
+ it("keeps the stored comparisonBaseline when the client omits it", async () => {
+ // The regression: a layout save from a client that doesn't know
+ // `comparisonBaseline` (the native client documents the field as
+ // web-only and never sends it) used to fall through to the serializer,
+ // which clamps a missing baseline to "none". The user's web-chosen
+ // comparison silently reset on every tile reorder from the phone.
+ const stored: DashboardLayout = serializeDashboardLayout({
+ version: 1,
+ widgets: [{ id: "weight", visible: true, tileVisible: true, order: 0 }],
+ comparisonBaseline: "lastYear",
+ });
+ vi.mocked(prisma.user.findUnique).mockResolvedValue({
+ dashboardWidgetsJson: stored,
+ } as never);
+ vi.mocked(prisma.user.update).mockResolvedValue({} as never);
+
+ const res = await callPut(
+ makeReq({
+ version: 1,
+ widgets: [
+ { id: "weight", visible: false, tileVisible: true, order: 0 },
+ ],
+ }),
+ );
+ expect(res.status).toBe(200);
+
+ const updateArg = vi.mocked(prisma.user.update).mock
+ .calls[0]?.[0] as unknown as {
+ data: { dashboardWidgetsJson: { comparisonBaseline: string } };
+ };
+ expect(updateArg.data.dashboardWidgetsJson.comparisonBaseline).toBe(
+ "lastYear",
+ );
+
+ const body = (await res.json()) as {
+ data: { comparisonBaseline: string };
+ };
+ expect(body.data.comparisonBaseline).toBe("lastYear");
+ });
+
+ it("honours an explicitly sent comparisonBaseline over the stored one", async () => {
+ // Preserve-when-absent must not become preserve-always: the web
+ // CompareToggle sends the field, including an explicit "none" to clear.
+ const stored: DashboardLayout = serializeDashboardLayout({
+ version: 1,
+ widgets: [{ id: "weight", visible: true, tileVisible: true, order: 0 }],
+ comparisonBaseline: "lastYear",
+ });
+ vi.mocked(prisma.user.findUnique).mockResolvedValue({
+ dashboardWidgetsJson: stored,
+ } as never);
+ vi.mocked(prisma.user.update).mockResolvedValue({} as never);
+
+ const res = await callPut(
+ makeReq({
+ version: 1,
+ widgets: [{ id: "weight", visible: true, tileVisible: true, order: 0 }],
+ comparisonBaseline: "none",
+ }),
+ );
+ expect(res.status).toBe(200);
+
+ const updateArg = vi.mocked(prisma.user.update).mock
+ .calls[0]?.[0] as unknown as {
+ data: { dashboardWidgetsJson: { comparisonBaseline: string } };
+ };
+ expect(updateArg.data.dashboardWidgetsJson.comparisonBaseline).toBe("none");
+ });
+
+ it("persists the four clinical tiles the native client pins", async () => {
+ // They used to hit the unknown-id filter ahead of Zod and vanish from
+ // the persisted layout, so the placement was lost on every save.
+ vi.mocked(prisma.user.findUnique).mockResolvedValue({
+ dashboardWidgetsJson: null,
+ } as never);
+ vi.mocked(prisma.user.update).mockResolvedValue({} as never);
+
+ const clinicalIds = [
+ "gripStrength",
+ "painNRS",
+ "waistCircumference",
+ "waistToHeight",
+ ];
+ const res = await callPut(
+ makeReq({
+ version: 1,
+ widgets: clinicalIds.map((id, i) => ({
+ id,
+ visible: true,
+ tileVisible: true,
+ order: i,
+ })),
+ }),
+ );
+ expect(res.status).toBe(200);
+
+ const dropAnnotate = vi
+ .mocked(annotate)
+ .mock.calls.find(
+ (c) =>
+ (c[0] as { action?: { name?: string } }).action?.name ===
+ "dashboard.widgets.unknown-id-dropped",
+ );
+ expect(dropAnnotate).toBeUndefined();
+
+ const updateArg = vi.mocked(prisma.user.update).mock
+ .calls[0]?.[0] as unknown as {
+ data: { dashboardWidgetsJson: { widgets: Array<{ id: string }> } };
+ };
+ const persistedIds = updateArg.data.dashboardWidgetsJson.widgets.map(
+ (w) => w.id,
+ );
+ for (const id of clinicalIds) {
+ expect(persistedIds).toContain(id);
+ }
+ });
+});
diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts
index 3522e63b6..71b4df844 100644
--- a/src/app/api/dashboard/widgets/route.ts
+++ b/src/app/api/dashboard/widgets/route.ts
@@ -26,10 +26,9 @@ import {
MAX_SELECTED_SCORE_RINGS,
HERO_RING_IDS,
MAX_HERO_RING_ORDER,
- type ChartOverlayPrefsMap,
+ layoutNeedsPreserveRead,
+ mergePreservedLayoutFields,
type DashboardLayout,
- type ScoreRingId,
- type HeroRingId,
} from "@/lib/dashboard-layout";
import { Prisma } from "@/generated/prisma/client";
import { z } from "zod/v4";
@@ -80,7 +79,12 @@ const layoutSchema = z.object({
// 11 iOS-only = 35) fits with headroom after the v1.10 additive
// HealthKit signals became pinnable; the enum still bounds each id to
// one of the catalogue ids.
- .max(40),
+ //
+ // Raised again to 50 when the four clinical signals joined the
+ // catalogue (42 ids). The cap only has to clear the catalogue size —
+ // the enum bounds each entry and the id set is closed — so it carries
+ // headroom rather than tracking the count exactly.
+ .max(50),
// v1.4.16 phase B8 — comparison baseline (Vormonat / Vorjahr) rides
// on the layout blob per research §7 Q3 (no Prisma migration). Optional
// so v1.4.15 clients that don't know the field can still PUT.
@@ -274,24 +278,27 @@ export const PUT = apiHandler(async (request: NextRequest) => {
return returnAllZodIssues(parsed.error, 422);
}
- // v1.4.18 — preserve any per-chart overlay prefs that the client
- // didn't send. The dashboard-layout PUT typically saves widget
- // visibility / order; chart prefs are PUT through their own route
- // (`/api/dashboard/chart-overlay-prefs`) and would otherwise be
- // wiped here on a subsequent layout save. `selectedScoreRings` and
- // `heroRingOrder` ride the same preserve-when-absent contract — an
- // older client's layout save must not reset either choice. One
- // stored-layout read covers all fallbacks.
- let mergedChartOverlayPrefs: ChartOverlayPrefsMap | undefined = parsed.data
- .chartOverlayPrefs as ChartOverlayPrefsMap | undefined;
- let mergedScoreRings: ScoreRingId[] | undefined =
- parsed.data.selectedScoreRings;
- let mergedHeroRingOrder: HeroRingId[] | undefined = parsed.data.heroRingOrder;
- if (
- mergedChartOverlayPrefs === undefined ||
- mergedScoreRings === undefined ||
- mergedHeroRingOrder === undefined
- ) {
+ // Preserve-when-absent: every field the client didn't send is carried
+ // forward from the stored layout rather than clamped to its default by
+ // the serializer. The dashboard-layout PUT typically saves widget
+ // visibility / order, while chart prefs are PUT through their own route
+ // (`/api/dashboard/chart-overlay-prefs`), the hero rings come from
+ // Settings, and `comparisonBaseline` is web-only — an omission from any
+ // one surface must not reset a choice made on another.
+ //
+ // The preserve set is not a list maintained here: it is derived from
+ // `LAYOUT_FIELD_MERGE_DISPOSITION`, which the type system forces to cover
+ // every field of `DashboardLayout`. Adding a layout field without a
+ // disposition fails typecheck, so the next field cannot silently miss this
+ // merge the way `comparisonBaseline` did. One stored-layout read covers
+ // every fallback, and the read is skipped entirely when the client sent
+ // the full set.
+ // The Zod shape leaves the per-chart `comparisonBaseline` optional while
+ // `ChartOverlayPrefs` requires it; `coerceChartOverlayPrefsMap` inside the
+ // serializer fills the gap, so the assertion is safe here exactly as it was
+ // on the previous per-field cast.
+ let toSerialize = parsed.data as Partial;
+ if (layoutNeedsPreserveRead(toSerialize)) {
const existing = await prisma.user.findUnique({
where: { id: user.id },
select: { dashboardWidgetsJson: true },
@@ -299,22 +306,9 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const existingLayout = resolveDashboardLayout(
existing?.dashboardWidgetsJson,
);
- if (mergedChartOverlayPrefs === undefined) {
- mergedChartOverlayPrefs = existingLayout.chartOverlayPrefs ?? {};
- }
- if (mergedScoreRings === undefined) {
- mergedScoreRings = existingLayout.selectedScoreRings;
- }
- if (mergedHeroRingOrder === undefined) {
- mergedHeroRingOrder = existingLayout.heroRingOrder;
- }
+ toSerialize = mergePreservedLayoutFields(toSerialize, existingLayout);
}
- const normalized = serializeDashboardLayout({
- ...parsed.data,
- chartOverlayPrefs: mergedChartOverlayPrefs,
- selectedScoreRings: mergedScoreRings,
- heroRingOrder: mergedHeroRingOrder,
- } as DashboardLayout);
+ const normalized = serializeDashboardLayout(toSerialize as DashboardLayout);
await prisma.user.update({
where: { id: user.id },
diff --git a/src/app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts b/src/app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts
index 68bc43f27..ff22da059 100644
--- a/src/app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts
+++ b/src/app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts
@@ -183,3 +183,64 @@ describe("PATCH /api/medications/[id]/inventory/[itemId] — unitsRemaining stoc
expect(res.status).toBe(422);
});
});
+
+describe("PATCH /api/medications/[id]/inventory/[itemId] — carton labelling", () => {
+ it("updates manufacturer + doseStrength when supplied", async () => {
+ vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({
+ id: "i1",
+ state: "ACTIVE",
+ } as never);
+
+ const res = await PATCH(
+ patchReq({
+ manufacturer: "Example Pharma",
+ doseStrength: "5 mg/0.5 ml",
+ }),
+ ROUTE_CTX,
+ );
+
+ expect(res.status).toBe(200);
+ const arg = vi.mocked(prisma.medicationInventoryItem.update).mock
+ .calls[0]?.[0] as unknown as {
+ data: Record;
+ };
+ expect(arg.data.manufacturer).toBe("Example Pharma");
+ expect(arg.data.doseStrength).toBe("5 mg/0.5 ml");
+ });
+
+ it("leaves both untouched when the request omits them", async () => {
+ // Absent must mean "I have nothing to say", never "blank it" — a client
+ // that does not know the fields must not wipe what another populated.
+ vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({
+ id: "i1",
+ state: "ACTIVE",
+ } as never);
+
+ const res = await PATCH(patchReq({ unitsRemaining: 2 }), ROUTE_CTX);
+
+ expect(res.status).toBe(200);
+ const arg = vi.mocked(prisma.medicationInventoryItem.update).mock
+ .calls[0]?.[0] as unknown as {
+ data: Record;
+ };
+ expect("manufacturer" in arg.data).toBe(false);
+ expect("doseStrength" in arg.data).toBe(false);
+ });
+
+ it("clears a value on an explicit null", async () => {
+ vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({
+ id: "i1",
+ state: "ACTIVE",
+ } as never);
+
+ const res = await PATCH(patchReq({ manufacturer: null }), ROUTE_CTX);
+
+ expect(res.status).toBe(200);
+ const arg = vi.mocked(prisma.medicationInventoryItem.update).mock
+ .calls[0]?.[0] as unknown as {
+ data: Record;
+ };
+ expect(arg.data.manufacturer).toBeNull();
+ expect("doseStrength" in arg.data).toBe(false);
+ });
+});
diff --git a/src/app/api/medications/[id]/inventory/[itemId]/route.ts b/src/app/api/medications/[id]/inventory/[itemId]/route.ts
index df65ba109..449ce8bfd 100644
--- a/src/app/api/medications/[id]/inventory/[itemId]/route.ts
+++ b/src/app/api/medications/[id]/inventory/[itemId]/route.ts
@@ -79,6 +79,8 @@ export const PATCH = apiHandler(
markAsUsedUp,
printedExpiry,
unitsRemaining,
+ manufacturer,
+ doseStrength,
notes,
} = parsed.data;
@@ -151,6 +153,16 @@ export const PATCH = apiHandler(
? {}
: { notesEncrypted: encryptNote(notes), notes: null };
+ // Carton labelling follows the same absent-means-untouched rule as
+ // `notes`: an omitted field must not blank a value the other client
+ // populated. Built field by field, never spread from the parsed body.
+ const labellingData: {
+ manufacturer?: string | null;
+ doseStrength?: string | null;
+ } = {};
+ if (manufacturer !== undefined) labellingData.manufacturer = manufacturer;
+ if (doseStrength !== undefined) labellingData.doseStrength = doseStrength;
+
const updated = await prisma.medicationInventoryItem.update({
where: { id: itemId },
data: {
@@ -159,6 +171,7 @@ export const PATCH = apiHandler(
unitsRemaining: nextUnitsRemaining,
printedExpiry: nextPrintedExpiry,
expiresAt: nextExpiresAt,
+ ...labellingData,
...notesData,
},
});
diff --git a/src/app/api/medications/[id]/inventory/__tests__/route.test.ts b/src/app/api/medications/[id]/inventory/__tests__/route.test.ts
index 78f4f33ed..ecbe8bfa3 100644
--- a/src/app/api/medications/[id]/inventory/__tests__/route.test.ts
+++ b/src/app/api/medications/[id]/inventory/__tests__/route.test.ts
@@ -471,3 +471,62 @@ describe("POST /api/medications/[id]/inventory — 422 multi-issue (v1.4.43 W6)"
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
});
+
+describe("POST /api/medications/[id]/inventory — carton labelling", () => {
+ it("persists manufacturer + doseStrength when the client sends them", async () => {
+ // The native pen list uses these two as its headline and subhead. With
+ // nowhere on the server to hold them, a container registered on the web
+ // could not be rendered there at all.
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
+ vi.mocked(prisma.medicationInventoryItem.create).mockResolvedValue({
+ id: "inv-new",
+ state: "ACTIVE",
+ } as never);
+
+ const res = await POST(
+ jsonReq("http://localhost/api/medications/med-1/inventory", {
+ unitsTotal: 4,
+ containerType: "PEN",
+ manufacturer: "Example Pharma",
+ doseStrength: "5 mg/0.5 ml",
+ }),
+ { params: Promise.resolve({ id: "med-1" }) },
+ );
+
+ expect(res.status).toBe(201);
+ expect(prisma.medicationInventoryItem.create).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ manufacturer: "Example Pharma",
+ doseStrength: "5 mg/0.5 ml",
+ }),
+ });
+ });
+
+ it("stores null for both when the client omits them", async () => {
+ // A plain supply row (blister, bottle) carries no carton labelling, and
+ // every row that predates the columns is truthfully null.
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
+ vi.mocked(prisma.medicationInventoryItem.create).mockResolvedValue({
+ id: "inv-new",
+ state: "ACTIVE",
+ } as never);
+
+ const res = await POST(
+ jsonReq("http://localhost/api/medications/med-1/inventory", {
+ unitsTotal: 30,
+ containerType: "BLISTER",
+ }),
+ { params: Promise.resolve({ id: "med-1" }) },
+ );
+
+ expect(res.status).toBe(201);
+ expect(prisma.medicationInventoryItem.create).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ manufacturer: null,
+ doseStrength: null,
+ }),
+ });
+ });
+});
diff --git a/src/app/api/medications/[id]/inventory/route.ts b/src/app/api/medications/[id]/inventory/route.ts
index d553ddee7..d22a8572b 100644
--- a/src/app/api/medications/[id]/inventory/route.ts
+++ b/src/app/api/medications/[id]/inventory/route.ts
@@ -121,8 +121,15 @@ export const POST = apiHandler(
return returnAllZodIssues(parsed.error, 422);
}
- const { unitsTotal, containerType, printedExpiry, purchasedAt, notes } =
- parsed.data;
+ const {
+ unitsTotal,
+ containerType,
+ printedExpiry,
+ purchasedAt,
+ manufacturer,
+ doseStrength,
+ notes,
+ } = parsed.data;
const created = await prisma.medicationInventoryItem.create({
// The wire field `unitsTotal` counts UNITS (tablets / ampoules /
@@ -135,6 +142,8 @@ export const POST = apiHandler(
containerType: containerType ?? "OTHER",
printedExpiry: printedExpiry ?? null,
purchasedAt: purchasedAt ?? null,
+ manufacturer: manufacturer ?? null,
+ doseStrength: doseStrength ?? null,
notes: notes ?? null,
}),
});
diff --git a/src/app/api/sleep/rhythm/route.ts b/src/app/api/sleep/rhythm/route.ts
index 50623ac82..898f8463b 100644
--- a/src/app/api/sleep/rhythm/route.ts
+++ b/src/app/api/sleep/rhythm/route.ts
@@ -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.
diff --git a/src/components/insights/sleep/sleep-debt-card.tsx b/src/components/insights/sleep/sleep-debt-card.tsx
index 350478c1b..6882e6bd1 100644
--- a/src/components/insights/sleep/sleep-debt-card.tsx
+++ b/src/components/insights/sleep/sleep-debt-card.tsx
@@ -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.
*/
diff --git a/src/components/medications/sections/inventory-section.tsx b/src/components/medications/sections/inventory-section.tsx
index fff2c12a0..d473fe8d5 100644
--- a/src/components/medications/sections/inventory-section.tsx
+++ b/src/components/medications/sections/inventory-section.tsx
@@ -492,6 +492,11 @@ export function AddInventoryDialog({
const [containerType, setContainerType] =
useState(initialContainerType);
const [expiry, setExpiry] = useState("");
+ // Carton labelling. Only offered for a PEN: the native pen list is the
+ // one surface that renders them, and asking for a maker + strength on a
+ // blister pack would be noise on the far commoner path.
+ const [manufacturer, setManufacturer] = useState("");
+ const [doseStrength, setDoseStrength] = useState("");
const [busy, setBusy] = useState(false);
const formId = useId();
@@ -513,6 +518,10 @@ export function AddInventoryDialog({
printedExpiry: expiry
? new Date(`${expiry}T00:00:00`).toISOString()
: null,
+ // Trimmed to null so a field the user opened and left blank stores
+ // absence rather than an empty string.
+ manufacturer: manufacturer.trim() || null,
+ doseStrength: doseStrength.trim() || null,
});
await invalidateSupplyQueries(queryClient, medicationId);
toast.success(t("medications.detail.bestand.addSuccess"));
@@ -664,6 +673,41 @@ export function AddInventoryDialog({
onChange={setExpiry}
/>
+ {containerType === "PEN" && (
+ <>
+
+ >
+ )}
);
diff --git a/src/components/settings/__tests__/destructive-controls-confirm.test.tsx b/src/components/settings/__tests__/destructive-controls-confirm.test.tsx
new file mode 100644
index 000000000..6d5b0d96c
--- /dev/null
+++ b/src/components/settings/__tests__/destructive-controls-confirm.test.tsx
@@ -0,0 +1,67 @@
+import { describe, it, expect } from "vitest";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+/**
+ * Destructive settings controls ask first.
+ *
+ * Signing every other device out, and revoking a trusted device, used to fire
+ * on a single tap. Neither is undoable — the other devices simply have to log
+ * in again, and a revoked trusted device asks for a second factor next time —
+ * while every other destructive surface in the app confirms. On a shared
+ * screen a mis-tap is a support call.
+ *
+ * This reads the sources rather than rendering, because what it pins is
+ * structural: that these particular controls route through `ConfirmButton`
+ * and never call a revoke mutation straight from an `onClick`. A render test
+ * would prove the dialog exists in one arrangement; this proves no control on
+ * these cards can go back to firing directly.
+ */
+
+const CARDS = [
+ {
+ file: "security-sessions-card.tsx",
+ slots: ["revoke-session", "sign-out-everywhere"],
+ },
+ {
+ file: "trusted-devices-card.tsx",
+ slots: ["revoke-trusted-device", "revoke-all-trusted-devices"],
+ },
+];
+
+function source(file: string) {
+ return readFileSync(
+ join(process.cwd(), "src", "components", "settings", file),
+ "utf8",
+ );
+}
+
+describe("destructive session controls confirm before firing", () => {
+ for (const card of CARDS) {
+ describe(card.file, () => {
+ const src = source(card.file);
+
+ it("routes every destructive control through ConfirmButton", () => {
+ expect(src).toContain("ConfirmButton");
+ for (const slot of card.slots) {
+ expect(src).toContain(`slot="${slot}"`);
+ }
+ });
+
+ it("never fires a revoke straight from an onClick", () => {
+ // `onConfirm={() => revoke…}` is the confirmed path and is fine;
+ // `onClick={() => revoke…}` is the unguarded one this forbids.
+ const unguarded = src.match(/onClick=\{\(\) =>\s*revoke/g) ?? [];
+ expect(unguarded).toEqual([]);
+ });
+
+ it("gives each control its own confirmation body", () => {
+ // A shared generic body would let one control's dialog describe
+ // another's consequence — the reason the copy is per-control.
+ const bodies = src.match(/body=\{t\("([^"]+)"/g) ?? [];
+ expect(bodies.length).toBe(card.slots.length);
+ expect(new Set(bodies).size).toBe(card.slots.length);
+ });
+ });
+ }
+});
diff --git a/src/components/settings/__tests__/sections.test.tsx b/src/components/settings/__tests__/sections.test.tsx
index b4363f3d2..6f014ac4d 100644
--- a/src/components/settings/__tests__/sections.test.tsx
+++ b/src/components/settings/__tests__/sections.test.tsx
@@ -267,6 +267,21 @@ describe("settings sections — SSR smoke", () => {
expect(html).not.toContain("settings.sections.thresholds.");
});
+ it(" 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", );
+ 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(" resolves the German title", () => {
const html = renderFramed("thresholds", , "de");
// v1.8.7.1 — German title is "Zielwerte" (Targets only).
diff --git a/src/components/settings/coach-reminders-section.tsx b/src/components/settings/coach-reminders-section.tsx
index f00e2a5da..c8eab020b 100644
--- a/src/components/settings/coach-reminders-section.tsx
+++ b/src/components/settings/coach-reminders-section.tsx
@@ -18,13 +18,14 @@
* Coach surface like the rest of the memory controls.
*/
import { useMemo } from "react";
-import { BellRing, Check, Loader2, Trash2, X } from "lucide-react";
+import { BellRing, Check, Trash2, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { formatDateOrRelative } from "@/lib/format";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
+import { ConfirmButton } from "@/components/settings/confirm-button";
import { useTranslations } from "@/lib/i18n/context";
import {
useCoachReminders,
@@ -129,25 +130,21 @@ export function CoachRemindersSection({
{t("settings.ai.coachReminders.dismiss")}
)}
-
+ disabled={!isAuthenticated}
+ pending={busy}
+ ariaLabel={t("settings.ai.coachReminders.deleteAria")}
+ label=""
+ icon={}
+ title={t("settings.ai.coachReminders.deleteConfirmTitle")}
+ body={t("settings.ai.coachReminders.deleteConfirmBody")}
+ confirmLabel={t("settings.ai.coachReminders.deleteConfirmAction")}
+ onConfirm={() => remove.mutate(r.id)}
+ />
);
diff --git a/src/components/settings/confirm-button.tsx b/src/components/settings/confirm-button.tsx
new file mode 100644
index 000000000..a6673f8bd
--- /dev/null
+++ b/src/components/settings/confirm-button.tsx
@@ -0,0 +1,119 @@
+"use client";
+
+/**
+ * A destructive settings control that asks first.
+ *
+ * The security card's "sign out everywhere" and its per-row revoke, and the
+ * same pair on trusted devices, fired on a single tap with no confirmation —
+ * while every other destructive surface in the app confirms. Signing every
+ * other device out is not recoverable by an undo; the other devices simply
+ * have to log in again, and on a shared screen a mis-tap is a support call.
+ *
+ * Wrapping the pattern rather than inlining a fourth dialog is the point: the
+ * next destructive control gets the confirmation by using this, instead of by
+ * someone remembering to add one.
+ *
+ * The copy contract matches the rest of the app after the delete-confirmation
+ * sweep — say what happens, and claim no more finality than is true. Callers
+ * pass their own body text so each control can be specific about its own
+ * consequence.
+ */
+
+import type { ReactNode } from "react";
+import { Loader2 } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@/components/ui/alert-dialog";
+import { useTranslations } from "@/lib/i18n/context";
+
+export function ConfirmButton({
+ label,
+ title,
+ body,
+ confirmLabel,
+ onConfirm,
+ pending = false,
+ disabled = false,
+ size,
+ variant = "outline",
+ className,
+ icon,
+ ariaLabel,
+ slot,
+}: {
+ /** The control's own label, before the dialog opens. */
+ label: string;
+ title: string;
+ body: string;
+ /** The dialog's action label. Names the act, not "OK". */
+ confirmLabel: string;
+ onConfirm: () => void;
+ pending?: boolean;
+ disabled?: boolean;
+ size?: "sm" | "icon";
+ variant?: "outline" | "ghost";
+ className?: string;
+ /** For an icon-only control, where `label` renders as an icon. */
+ ariaLabel?: string;
+ icon?: ReactNode;
+ /** `data-slot` for tests and the design guards. */
+ slot?: string;
+}) {
+ const { t } = useTranslations();
+
+ return (
+
+
+
+
+
+
+ {title}
+ {body}
+
+
+ {t("common.cancel")}
+
+ {pending && (
+
+ )}
+ {confirmLabel}
+
+
+
+
+ );
+}
diff --git a/src/components/settings/environment-section.tsx b/src/components/settings/environment-section.tsx
index d129eee3b..b807a38d0 100644
--- a/src/components/settings/environment-section.tsx
+++ b/src/components/settings/environment-section.tsx
@@ -27,6 +27,7 @@ import { DateField } from "@/components/ui/date-field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useAuth } from "@/hooks/use-auth";
+import { ConfirmButton } from "@/components/settings/confirm-button";
import { useTranslations } from "@/lib/i18n/context";
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
@@ -325,19 +326,26 @@ export function EnvironmentSection() {
{tr.label} · {tr.startDate} – {tr.endDate}
- }
+ pending={removeTravel.isPending}
+ title={t(
+ "settings.sections.environment.travel.removeConfirmTitle",
+ )}
+ body={t(
+ "settings.sections.environment.travel.removeConfirmBody",
+ )}
+ confirmLabel={t(
"settings.sections.environment.travel.remove",
)}
- disabled={removeTravel.isPending}
- onClick={() => removeTravel.mutate(tr.id)}
- >
-
-
+ onConfirm={() => removeTravel.mutate(tr.id)}
+ />
))}
diff --git a/src/components/settings/glucose-reference-card.tsx b/src/components/settings/glucose-reference-card.tsx
new file mode 100644
index 000000000..c4579ca81
--- /dev/null
+++ b/src/components/settings/glucose-reference-card.tsx
@@ -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("/api/auth/me/diabetes"),
+ });
+
+ const toggle = useMutation({
+ mutationFn: async (hasDiabetes: boolean) =>
+ apiPatch("/api/auth/me/diabetes", { hasDiabetes }),
+ onSuccess: (next) => {
+ queryClient.setQueryData(queryKeys.diabetesPref(), next);
+ void queryClient.invalidateQueries({
+ queryKey: queryKeys.insightsTargets(),
+ });
+ },
+ });
+
+ const enabled = data?.hasDiabetes ?? false;
+
+ return (
+
+
+
)}
diff --git a/src/components/settings/thresholds-section.tsx b/src/components/settings/thresholds-section.tsx
index 2b94ab727..0af5bd747 100644
--- a/src/components/settings/thresholds-section.tsx
+++ b/src/components/settings/thresholds-section.tsx
@@ -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 ;
+ // 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 (
+
+
+
+
+ );
}
diff --git a/src/components/settings/trusted-devices-card.tsx b/src/components/settings/trusted-devices-card.tsx
index 79d2745a7..104176f91 100644
--- a/src/components/settings/trusted-devices-card.tsx
+++ b/src/components/settings/trusted-devices-card.tsx
@@ -12,8 +12,8 @@ import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Loader2, ShieldCheck } from "lucide-react";
-import { Button } from "@/components/ui/button";
import { SettingsCard } from "@/components/settings/settings-card";
+import { ConfirmButton } from "@/components/settings/confirm-button";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -130,16 +130,19 @@ export function TrustedDevicesCard({
})}
)}
diff --git a/src/lib/__tests__/dashboard-layout.test.ts b/src/lib/__tests__/dashboard-layout.test.ts
index 8624620f6..8379f5c0c 100644
--- a/src/lib/__tests__/dashboard-layout.test.ts
+++ b/src/lib/__tests__/dashboard-layout.test.ts
@@ -9,6 +9,10 @@ import {
IOS_PIN_ONLY_WIDGET_IDS,
DEFAULT_HERO_RING_ORDER,
resolveHeroRingOrder,
+ LAYOUT_FIELD_MERGE_DISPOSITION,
+ PRESERVED_LAYOUT_FIELDS,
+ layoutNeedsPreserveRead,
+ mergePreservedLayoutFields,
type DashboardLayout,
} from "@/lib/dashboard-layout";
@@ -438,7 +442,7 @@ describe("resolveDashboardLayout() — retired heroVisible field", () => {
* iOS-only ids extend it without touching the writable PUT enum.
*/
describe("DASHBOARD_WIDGET_CATALOGUE_IDS — 27-id catalogue", () => {
- it("carries exactly 37 distinct ids (28 server-known + 9 iOS-only)", () => {
+ it("carries exactly 42 distinct ids (29 server-known + 13 iOS-only)", () => {
// v1.11.2 B5 — the 8 v1.10 additive metrics became web-writable, so
// the server-known set grew 16 → 24 and the catalogue 27 → 35.
// v1.18.2 — the Vorsorge summary widget added one server-known id
@@ -450,10 +454,42 @@ describe("DASHBOARD_WIDGET_CATALOGUE_IDS — 27-id catalogue", () => {
// v1.29 — the nutrients-store-backed fluid-intake strip tile
// (`waterIntake`) added one server-known id, so the catalogue grew
// 37 → 38.
+ // The four clinical signals the native client pins (grip strength, pain
+ // NRS, waist circumference, waist-to-height) joined the iOS-only set
+ // (9 → 13), so the catalogue grew 38 → 42. Before they were catalogued
+ // the widgets PUT dropped them as unknown ids and every native layout
+ // save lost the user's placement of those tiles.
expect(DASHBOARD_WIDGET_IDS).toHaveLength(29);
- expect(DASHBOARD_IOS_ONLY_WIDGET_IDS).toHaveLength(9);
- expect(DASHBOARD_WIDGET_CATALOGUE_IDS).toHaveLength(38);
- expect(new Set(DASHBOARD_WIDGET_CATALOGUE_IDS).size).toBe(38);
+ expect(DASHBOARD_IOS_ONLY_WIDGET_IDS).toHaveLength(13);
+ expect(DASHBOARD_WIDGET_CATALOGUE_IDS).toHaveLength(42);
+ expect(new Set(DASHBOARD_WIDGET_CATALOGUE_IDS).size).toBe(42);
+ });
+
+ it("catalogues the four clinical signals the native client pins", () => {
+ // Regression guard: these four reached the widgets PUT as unknown ids,
+ // were filtered out before Zod, and vanished from the persisted layout
+ // — a silent loss of the user's tile placement on every save.
+ for (const id of [
+ "gripStrength",
+ "painNRS",
+ "waistCircumference",
+ "waistToHeight",
+ ]) {
+ expect(DASHBOARD_WIDGET_CATALOGUE_IDS).toContain(id);
+ }
+ });
+
+ it("keeps the four clinical signals out of the web-writable set", () => {
+ // The web dashboard has no render path for them — their charts live on
+ // the /insights sub-pages — so a web Settings toggle would do nothing.
+ for (const id of [
+ "gripStrength",
+ "painNRS",
+ "waistCircumference",
+ "waistToHeight",
+ ]) {
+ expect(DASHBOARD_WIDGET_IDS).not.toContain(id);
+ }
});
it("ships the sleep / steps / glucose strip tiles default-on (v1.20.0)", () => {
@@ -498,6 +534,9 @@ describe("DASHBOARD_WIDGET_CATALOGUE_IDS — 27-id catalogue", () => {
it("carries the locked iOS-only ids verbatim", () => {
// v1.28.52 — `hrv` + `respiratoryRate` left this set for the writable
// DASHBOARD_WIDGET_IDS (they gained a web strip tile).
+ // The four clinical signals joined so the widgets PUT stops dropping
+ // them as unknown ids; `painNRS` is spelled as the native client sends
+ // it, which differs in case from the `painNrs` chart-overlay slot.
expect([...DASHBOARD_IOS_ONLY_WIDGET_IDS]).toEqual([
"restingHeartRate",
"walkingSpeed",
@@ -508,6 +547,10 @@ describe("DASHBOARD_WIDGET_CATALOGUE_IDS — 27-id catalogue", () => {
"walkingDoubleSupport",
"audioExposureEnvironment",
"audioExposureHeadphone",
+ "gripStrength",
+ "painNRS",
+ "waistCircumference",
+ "waistToHeight",
]);
});
});
@@ -909,3 +952,137 @@ describe("resolveDashboardLayout() / serialize — heroRingOrder", () => {
expect(DEFAULT_HERO_RING_ORDER[0]).toBe("HEALTH_SCORE");
});
});
+
+/**
+ * Preserve-when-absent contract for the widgets PUT.
+ *
+ * `comparisonBaseline` was a top-level layout field that the merge forgot.
+ * Because `serializeDashboardLayout` clamps a missing baseline to `"none"`,
+ * every layout save from a client that doesn't send the field — the native
+ * client documents it as web-only and never sends it — silently reset the
+ * comparison baseline the user had picked on the web. No error, no audit row.
+ *
+ * The fix is not "remember to add the field to the merge". It is a
+ * disposition map the type system forces to cover every field of
+ * `DashboardLayout`, from which the preserve set is derived. These tests pin
+ * the behaviour that map buys.
+ */
+describe("layout field merge disposition", () => {
+ /**
+ * A layout with EVERY top-level field populated. Typed as
+ * `Required` so adding a field to the interface without
+ * adding it here is a type error too — the runtime assertion below then
+ * proves the disposition map covers it.
+ */
+ const fullyPopulatedLayout: Required = {
+ version: 1,
+ widgets: [{ id: "weight", visible: true, tileVisible: true, order: 0 }],
+ comparisonBaseline: "lastYear",
+ chartOverlayPrefs: {
+ weight: {
+ showTrendIndicator: true,
+ showTrendArrow: false,
+ showTargetRange: false,
+ comparisonBaseline: "none",
+ },
+ },
+ selectedScoreRings: ["MED_COMPLIANCE"],
+ heroRingOrder: ["HEALTH_SCORE", "MED_COMPLIANCE"],
+ };
+
+ it("assigns a disposition to every top-level layout field", () => {
+ // The guard that makes the next field impossible to forget. Adding a
+ // field to `DashboardLayout` without a disposition fails typecheck at
+ // the `satisfies Record`; this asserts the
+ // same coverage at runtime so the failure is loud in the suite too.
+ expect(Object.keys(LAYOUT_FIELD_MERGE_DISPOSITION).sort()).toEqual(
+ Object.keys(fullyPopulatedLayout).sort(),
+ );
+ });
+
+ it("only ever assigns a known disposition", () => {
+ for (const disposition of Object.values(LAYOUT_FIELD_MERGE_DISPOSITION)) {
+ expect(["replace", "preserve"]).toContain(disposition);
+ }
+ });
+
+ it("preserves comparisonBaseline alongside the other client-owned fields", () => {
+ // The regression itself: `comparisonBaseline` must be in the preserve
+ // set, not merely present in the disposition map.
+ expect([...PRESERVED_LAYOUT_FIELDS].sort()).toEqual([
+ "chartOverlayPrefs",
+ "comparisonBaseline",
+ "heroRingOrder",
+ "selectedScoreRings",
+ ]);
+ });
+
+ it("replaces the fields the request itself owns", () => {
+ expect(LAYOUT_FIELD_MERGE_DISPOSITION.version).toBe("replace");
+ expect(LAYOUT_FIELD_MERGE_DISPOSITION.widgets).toBe("replace");
+ });
+
+ it("skips the stored-layout read when the client sent every preserved field", () => {
+ expect(layoutNeedsPreserveRead(fullyPopulatedLayout)).toBe(false);
+ });
+
+ it("requires the stored-layout read when any preserved field is absent", () => {
+ for (const field of PRESERVED_LAYOUT_FIELDS) {
+ const incoming: Partial = { ...fullyPopulatedLayout };
+ delete incoming[field];
+ expect(layoutNeedsPreserveRead(incoming)).toBe(true);
+ }
+ });
+
+ it("carries an omitted comparisonBaseline forward from the stored layout", () => {
+ // A widgets-only save, which is exactly what the native client sends.
+ const incoming: Partial = {
+ version: 1,
+ widgets: fullyPopulatedLayout.widgets,
+ };
+ const merged = mergePreservedLayoutFields(incoming, fullyPopulatedLayout);
+ expect(merged.comparisonBaseline).toBe("lastYear");
+ });
+
+ it("carries every omitted preserved field forward, one field at a time", () => {
+ for (const field of PRESERVED_LAYOUT_FIELDS) {
+ const incoming: Partial = { ...fullyPopulatedLayout };
+ delete incoming[field];
+ const merged = mergePreservedLayoutFields(incoming, fullyPopulatedLayout);
+ expect(merged[field]).toEqual(fullyPopulatedLayout[field]);
+ }
+ });
+
+ it("never overwrites a preserved field the client did send", () => {
+ const stored: DashboardLayout = {
+ ...fullyPopulatedLayout,
+ comparisonBaseline: "lastMonth",
+ selectedScoreRings: ["MED_COMPLIANCE"],
+ };
+ const incoming: Partial = {
+ version: 1,
+ widgets: fullyPopulatedLayout.widgets,
+ comparisonBaseline: "none",
+ chartOverlayPrefs: fullyPopulatedLayout.chartOverlayPrefs,
+ selectedScoreRings: [],
+ heroRingOrder: fullyPopulatedLayout.heroRingOrder,
+ };
+ const merged = mergePreservedLayoutFields(incoming, stored);
+ // An explicit "none" is a real choice — clearing must survive the merge.
+ expect(merged.comparisonBaseline).toBe("none");
+ expect(merged.selectedScoreRings).toEqual([]);
+ });
+
+ it("leaves the replace-fields untouched by the merge", () => {
+ const stored: DashboardLayout = {
+ ...fullyPopulatedLayout,
+ widgets: [{ id: "bp", visible: false, tileVisible: false, order: 0 }],
+ };
+ const incoming: Partial = {
+ version: 1,
+ widgets: fullyPopulatedLayout.widgets,
+ };
+ const merged = mergePreservedLayoutFields(incoming, stored);
+ expect(merged.widgets).toEqual(fullyPopulatedLayout.widgets);
+ });
+});
diff --git a/src/lib/ai/coach/__tests__/system-prompt.test.ts b/src/lib/ai/coach/__tests__/system-prompt.test.ts
index 0ccf9fd45..50208afb9 100644
--- a/src/lib/ai/coach/__tests__/system-prompt.test.ts
+++ b/src/lib/ai/coach/__tests__/system-prompt.test.ts
@@ -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)", () => {
diff --git a/src/lib/ai/coach/snapshot-blocks/sleep-block.ts b/src/lib/ai/coach/snapshot-blocks/sleep-block.ts
index 199ec03cd..b67802d55 100644
--- a/src/lib/ai/coach/snapshot-blocks/sleep-block.ts
+++ b/src/lib/ai/coach/snapshot-blocks/sleep-block.ts
@@ -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
diff --git a/src/lib/ai/coach/system-prompt.ts b/src/lib/ai/coach/system-prompt.ts
index 8aa773ecd..32433eb9f 100644
--- a/src/lib/ai/coach/system-prompt.ts
+++ b/src/lib/ai/coach/system-prompt.ts
@@ -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
diff --git a/src/lib/auth/__tests__/passkey.test.ts b/src/lib/auth/__tests__/passkey.test.ts
index 26e08215f..d7edcda31 100644
--- a/src/lib/auth/__tests__/passkey.test.ts
+++ b/src/lib/auth/__tests__/passkey.test.ts
@@ -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();
+ });
+});
diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts
index dcf249db0..54447cc5d 100644
--- a/src/lib/dashboard-layout.ts
+++ b/src/lib/dashboard-layout.ts
@@ -139,6 +139,27 @@ export const DASHBOARD_IOS_ONLY_WIDGET_IDS = [
"walkingDoubleSupport",
"audioExposureEnvironment",
"audioExposureHeadphone",
+ // The four clinical signals the v1.25 sub-pages already chart
+ // (`/insights/grip-strength`, `/insights/pain`, `/insights/waist`,
+ // `/insights/waist-to-height`). The native client offers a dashboard tile
+ // for each; before they joined the catalogue the widgets PUT dropped them
+ // through the unknown-id filter, so every native layout save silently lost
+ // the user's placement of those four tiles.
+ //
+ // They live here rather than in `DASHBOARD_WIDGET_IDS` because the web
+ // dashboard has no render path for them — the chart lives on the insights
+ // sub-page, not on the Startseite. That keeps them out of the web Settings
+ // list (built from `DASHBOARD_WIDGET_IDS`) while the PUT enum, the on-read
+ // resolver, and the snapshot catalogue all accept and retain them.
+ //
+ // `painNRS` matches the id the native client sends. The sibling
+ // chart-overlay slot spells it `painNrs` (see `CHART_OVERLAY_KEYS`); the
+ // two namespaces are independent and neither is derived from the other, so
+ // the casing difference is deliberate rather than a typo.
+ "gripStrength",
+ "painNRS",
+ "waistCircumference",
+ "waistToHeight",
] as const;
/**
@@ -548,6 +569,84 @@ export interface DashboardLayout {
heroRingOrder?: HeroRingId[];
}
+/**
+ * How the widgets PUT treats each top-level layout field when the client
+ * omits it.
+ *
+ * - `"replace"` — the field is the point of the request. A PUT that omits it
+ * is either rejected by Zod (`version`, `widgets` are required) or means
+ * the caller genuinely has nothing to say.
+ * - `"preserve"` — the field is owned by a surface OTHER than the one making
+ * this request, so an omission means "I don't know about this", never
+ * "clear it". The route reads the stored layout and carries the existing
+ * value forward.
+ *
+ * The preserve set exists because clients legitimately don't know every
+ * field. The per-chart overlay popover PUTs through
+ * `/api/dashboard/chart-overlay-prefs`; the hero rings are a Settings-only
+ * control; `comparisonBaseline` is web-only and the native client documents
+ * that it never sends it. Without preserve-when-absent, a tile reorder on one
+ * surface silently resets a choice made on another — which is exactly what
+ * happened to `comparisonBaseline` before it joined this map: the serializer
+ * clamps a missing value to `"none"`, so every native layout save wiped the
+ * baseline the user had picked on the web.
+ *
+ * `satisfies Record` is the load-bearing part.
+ * Adding a field to `DashboardLayout` without adding it here is a TYPE error,
+ * so the next field cannot repeat the `comparisonBaseline` mistake by
+ * omission — somebody has to make the decision explicitly.
+ */
+export const LAYOUT_FIELD_MERGE_DISPOSITION = {
+ version: "replace",
+ widgets: "replace",
+ comparisonBaseline: "preserve",
+ chartOverlayPrefs: "preserve",
+ selectedScoreRings: "preserve",
+ heroRingOrder: "preserve",
+} as const satisfies Record;
+
+/**
+ * The top-level layout fields the widgets PUT carries forward from the stored
+ * layout when the request omits them. Derived from
+ * `LAYOUT_FIELD_MERGE_DISPOSITION` so the two can never drift.
+ */
+export const PRESERVED_LAYOUT_FIELDS = (
+ Object.keys(LAYOUT_FIELD_MERGE_DISPOSITION) as Array
+).filter(
+ (field) => LAYOUT_FIELD_MERGE_DISPOSITION[field] === "preserve",
+) as ReadonlyArray;
+
+/**
+ * True when the incoming layout omits at least one preserved field, i.e. the
+ * route has to read the stored layout to merge. Lets the common full-layout
+ * save from Settings skip the extra query.
+ */
+export function layoutNeedsPreserveRead(
+ incoming: Partial,
+): boolean {
+ return PRESERVED_LAYOUT_FIELDS.some((field) => incoming[field] === undefined);
+}
+
+/**
+ * Carry every omitted preserved field forward from the stored layout.
+ * Fields the client DID send win untouched; `"replace"` fields are never
+ * consulted here. One stored-layout read covers all of them.
+ */
+export function mergePreservedLayoutFields(
+ incoming: Partial,
+ existing: DashboardLayout,
+): Partial {
+ const merged: Partial = { ...incoming };
+ for (const field of PRESERVED_LAYOUT_FIELDS) {
+ if (merged[field] === undefined) {
+ // Each key maps to its own value type; the loop is homogeneous over
+ // `keyof DashboardLayout`, which TypeScript cannot express per-key.
+ (merged as Record)[field] = existing[field];
+ }
+ }
+ return merged;
+}
+
const DASHBOARD_LAYOUT_VERSION = 1;
/**
diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts
index f267aa23e..19ef63e60 100644
--- a/src/lib/dashboard/__tests__/snapshot.test.ts
+++ b/src/lib/dashboard/__tests__/snapshot.test.ts
@@ -752,11 +752,11 @@ describe("buildDashboardSnapshot — layoutCatalogue (35-id round-trip)", () =>
isFullyCovered.mockReturnValue(false);
});
- it("emits all 38 catalogue ids with visibility + order", async () => {
+ it("emits all 42 catalogue ids with visibility + order", async () => {
const snap = await buildDashboardSnapshot(fakePrisma, baseUser());
- expect(snap.layoutCatalogue).toHaveLength(38);
+ expect(snap.layoutCatalogue).toHaveLength(42);
const ids = new Set(snap.layoutCatalogue.map((w) => w.id));
- expect(ids.size).toBe(38);
+ expect(ids.size).toBe(42);
// iOS-only ids appended default-invisible.
const walkingSpeed = snap.layoutCatalogue.find(
(w) => w.id === "walkingSpeed",
diff --git a/src/lib/dashboard/widget-modules.ts b/src/lib/dashboard/widget-modules.ts
index ee12a8dfa..3bddac9d8 100644
--- a/src/lib/dashboard/widget-modules.ts
+++ b/src/lib/dashboard/widget-modules.ts
@@ -74,6 +74,11 @@ export const SUMMARY_TYPE_MODULE: Partial> = {
// `NutrientIntakeDay` (nutrient="water", summed across sources). Gated
// on the `nutrients` module like the rest of that store's surfaces.
NUTRIENT_WATER: "nutrients",
+ // Mood is a `MoodEntry`-derived summary, not a `MeasurementType`, so it
+ // rides the same synthetic-key route as `NUTRIENT_WATER`. Gated on the
+ // `mood` module: turning the module off must drop the dashboard card on
+ // every client, not just hide the web widget.
+ MOOD_ENTRY: "mood",
};
/**
@@ -123,6 +128,13 @@ export const SUMMARY_METRIC_TYPE_BY_KIND: Record = {
totalBodyWater: "TOTAL_BODY_WATER",
boneMass: "BONE_MASS",
oxygenSaturation: "OXYGEN_SATURATION",
+ // Synthetic key — see `MOOD_ENTRY` in `SUMMARY_TYPE_MODULE`.
+ mood: "MOOD_ENTRY",
+ // BMI is derived from weight + the profile height and belongs to no
+ // module, exactly like weight and body fat. Listed so the full emitted
+ // set stays readable and a future card cannot be added without deciding
+ // what backs it.
+ bmi: "WEIGHT",
};
/**
diff --git a/src/lib/insights/derived/sleep-rhythm.ts b/src/lib/insights/derived/sleep-rhythm.ts
index 63ac5f015..2af5f9966 100644
--- a/src/lib/insights/derived/sleep-rhythm.ts
+++ b/src/lib/insights/derived/sleep-rhythm.ts
@@ -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
diff --git a/src/lib/medications/inventory/__tests__/service.test.ts b/src/lib/medications/inventory/__tests__/service.test.ts
index 20509e5ae..0c5751b42 100644
--- a/src/lib/medications/inventory/__tests__/service.test.ts
+++ b/src/lib/medications/inventory/__tests__/service.test.ts
@@ -80,6 +80,8 @@ describe("buildCreateInventoryInput", () => {
containerType: "PEN",
printedExpiry: printed,
purchasedAt: null,
+ manufacturer: null,
+ doseStrength: null,
notes: null,
});
expect(input).toMatchObject({
@@ -100,6 +102,8 @@ describe("buildCreateInventoryInput", () => {
containerType: "OTHER",
printedExpiry: null,
purchasedAt: null,
+ manufacturer: null,
+ doseStrength: null,
notes: null,
});
expect(input.expiresAt).toBeNull();
@@ -157,3 +161,40 @@ describe("serializeInventoryItem (iOS#31)", () => {
expect(out).toMatchObject({ id: "i5", state: "ACTIVE" });
});
});
+
+describe("buildCreateInventoryInput — carton labelling", () => {
+ it("carries manufacturer + doseStrength onto the create input", () => {
+ // The native pen list renders these as its headline and subhead; a
+ // container created on the web carried neither, so the row could not be
+ // shown there without fabricating an empty card.
+ const input = buildCreateInventoryInput({
+ userId: "u",
+ medicationId: "m",
+ unitsTotal: 4,
+ containerType: "PEN",
+ printedExpiry: null,
+ purchasedAt: null,
+ manufacturer: "Example Pharma",
+ doseStrength: "5 mg/0.5 ml",
+ notes: null,
+ });
+ expect(input.manufacturer).toBe("Example Pharma");
+ expect(input.doseStrength).toBe("5 mg/0.5 ml");
+ });
+
+ it("leaves both null for a plain supply container", () => {
+ const input = buildCreateInventoryInput({
+ userId: "u",
+ medicationId: "m",
+ unitsTotal: 30,
+ containerType: "BLISTER",
+ printedExpiry: null,
+ purchasedAt: null,
+ manufacturer: null,
+ doseStrength: null,
+ notes: null,
+ });
+ expect(input.manufacturer).toBeNull();
+ expect(input.doseStrength).toBeNull();
+ });
+});
diff --git a/src/lib/medications/inventory/service.ts b/src/lib/medications/inventory/service.ts
index 13b4c4919..2b77e2e34 100644
--- a/src/lib/medications/inventory/service.ts
+++ b/src/lib/medications/inventory/service.ts
@@ -144,6 +144,8 @@ export function buildCreateInventoryInput(input: {
containerType: MedicationContainerType;
printedExpiry: Date | null;
purchasedAt: Date | null;
+ manufacturer: string | null;
+ doseStrength: string | null;
notes: string | null;
}) {
return {
@@ -157,6 +159,8 @@ export function buildCreateInventoryInput(input: {
printedExpiry: input.printedExpiry,
purchasedAt: input.purchasedAt,
expiresAt: computeExpiresAt(null, input.printedExpiry),
+ manufacturer: input.manufacturer,
+ doseStrength: input.doseStrength,
// Encrypt the free-text note at rest; the plaintext column stays null.
notesEncrypted: encryptNote(input.notes),
notes: null,
diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts
index d29e22e65..23ad8f028 100644
--- a/src/lib/openapi/routes/measurements.ts
+++ b/src/lib/openapi/routes/measurements.ts
@@ -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 = {
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.",
diff --git a/src/lib/openapi/routes/medications/schemas.ts b/src/lib/openapi/routes/medications/schemas.ts
index 57df825a7..6e038efaf 100644
--- a/src/lib/openapi/routes/medications/schemas.ts
+++ b/src/lib/openapi/routes/medications/schemas.ts
@@ -314,6 +314,18 @@ export const medicationInventoryItemResource = z
),
printedExpiry: z.iso.datetime({ offset: true }).nullable(),
purchasedAt: z.iso.datetime({ offset: true }).nullable(),
+ manufacturer: z
+ .string()
+ .nullable()
+ .describe(
+ "Marketing-authorisation holder / maker as printed on the carton. NULL for a container registered before the field existed, and for plain supply rows that never carried one.",
+ ),
+ doseStrength: z
+ .string()
+ .nullable()
+ .describe(
+ 'Strength as printed on the container, e.g. "5 mg/0.5 ml". Free text, not split into a number + unit — pens state strength per dose, per ml, or per cartridge. NULL when unknown.',
+ ),
notes: z.string().nullable(),
createdAt: z.iso.datetime({ offset: true }),
updatedAt: z.iso.datetime({ offset: true }),
diff --git a/src/lib/query-keys/settings.ts b/src/lib/query-keys/settings.ts
index 91d44dc20..f5975ae0f 100644
--- a/src/lib/query-keys/settings.ts
+++ b/src/lib/query-keys/settings.ts
@@ -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;
diff --git a/src/lib/validations/medication/inventory.ts b/src/lib/validations/medication/inventory.ts
index 5e735b085..1c33a83d0 100644
--- a/src/lib/validations/medication/inventory.ts
+++ b/src/lib/validations/medication/inventory.ts
@@ -54,6 +54,24 @@ export const createInventoryItemSchema = z
.transform((s) => new Date(s))
.nullable()
.optional(),
+ /** Carton labelling — free text, see the schema comment on the model
+ * for why neither is parsed into a structured value. */
+ manufacturer: z
+ .string()
+ .max(120)
+ .nullable()
+ .optional()
+ .describe(
+ "Marketing-authorisation holder / maker as printed on the carton. Free text, max 120 chars.",
+ ),
+ doseStrength: z
+ .string()
+ .max(60)
+ .nullable()
+ .optional()
+ .describe(
+ 'Strength as printed on the container, e.g. "5 mg/0.5 ml". Free text, max 60 chars — deliberately not split into a number + unit because pens state strength per dose, per ml, or per cartridge.',
+ ),
notes: z.string().max(200).nullable().optional(),
})
.meta({
@@ -99,6 +117,24 @@ export const updateInventoryItemSchema = z
.describe(
"Absolute remaining-unit correction (0–1000, fractional allowed). Clamped server-side to the item's `unitsTotal`; the canonical state machine re-derives the state (0 ⇒ USED_UP).",
),
+ /** Carton labelling — free text, see the schema comment on the model
+ * for why neither is parsed into a structured value. */
+ manufacturer: z
+ .string()
+ .max(120)
+ .nullable()
+ .optional()
+ .describe(
+ "Marketing-authorisation holder / maker as printed on the carton. Free text, max 120 chars.",
+ ),
+ doseStrength: z
+ .string()
+ .max(60)
+ .nullable()
+ .optional()
+ .describe(
+ 'Strength as printed on the container, e.g. "5 mg/0.5 ml". Free text, max 60 chars — deliberately not split into a number + unit because pens state strength per dose, per ml, or per cartridge.',
+ ),
notes: z.string().max(200).nullable().optional(),
})
.meta({