Merge remote-tracking branch 'origin/main' into work/v131-slice3-activity

This commit is contained in:
Marc-André Bombeck committed 2026-07-19 14:16:29 +02:00
commit 048d7a4d84
54 files changed
+2507 -346

No files matched your search

+56
View File
@@ -2,6 +2,62 @@
## [Unreleased]
## [1.30.35] — 2026-07-19
- **The metric texts you see without an AI provider now speak your
language.** They shipped in German and English only; French, Spanish,
Italian and Polish fell through to English. These are what nine metric
cards render whenever no provider is reachable — no key configured, an
outage, a spent budget — so for those readers this text was the app's
voice, in the wrong language. Written per language rather than
translated: where a sentence could not be formed naturally, that
language got its own sentence.
- **The lab-marker card leads with what a value means**, like every other
card already did. It was the last one still instructed to state the
number first.
- **The response length for a metric assessment is set in one place**
again, instead of one card carrying its own inline pair.
- **The fallback texts open on something honest** rather than on an
instruction, and the lab-marker card no longer shows the multi-metric
overview tip on a single value.
No migrations. No breaking changes.
## [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
+52 -11
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.30.33
version: 1.30.35
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.
@@ -12427,6 +12427,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
@@ -12460,6 +12473,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
@@ -17932,12 +17958,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
@@ -18078,9 +18105,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:
@@ -19437,6 +19464,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
@@ -19461,6 +19500,8 @@ components:
- expiresAt
- printedExpiry
- purchasedAt
- manufacturer
- doseStrength
- notes
- createdAt
- updatedAt
+41 -13
View File
@@ -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",
+41 -13
View File
@@ -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",
+41 -13
View File
@@ -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",
+41 -13
View File
@@ -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",
+41 -13
View File
@@ -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",
+41 -13
View File
@@ -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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.30.33",
"version": "1.30.35",
"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",
@@ -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;
+12
View File
@@ -2922,6 +2922,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
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.30.33";
/* @sw-version-fallback */ "v1.30.35";
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/*`
@@ -0,0 +1,219 @@
/**
* Copy-matches-behaviour guard.
*
* Three classes of copy that drifted away from the code they describe, each
* pinned here so the pair fails loudly if either half moves again:
*
* 1. DELETE CONFIRMATIONS. A confirmation is the moment a user weighs the
* consequence. Four dialogs promised permanence on surfaces that render
* an Undo affordance (measurements bulk, mood bulk, labs, illness) — a
* cautious user declines a safe delete, and the warning loses weight
* where it is true. This test pins both directions: undo-able surfaces
* must offer the undo sentence and must NOT claim finality; the
* genuinely permanent ones must keep their warning.
*
* 2. SLEEP DEBT. v1.19.0 replaced the summed shortfall with a running
* balance that credits surplus sleep. The labels still described the old
* arithmetic, so the word contradicted the number for anyone who caught
* up after a short night.
*
* 3. GLUCOSE REFERENCE BAND. The declared diabetes opt-in has a server
* preference and a route; the copy for its web control must exist in
* every locale, since a missing key silently degrades to the raw key on
* a surface that makes a clinical-adjacent claim.
*
* Reversibility evidence for the undo-able set:
* - measurements: `POST /api/measurements/restore`, undo toast in
* `src/components/measurements/measurement-list.tsx`
* - mood: `POST /api/mood-entries/restore`, undo toast in
* `src/components/mood/mood-list.tsx`
* - labs: `POST /api/labs/restore`, undo toast in
* `src/components/labs/lab-history-list.tsx`
* - illness: `POST /api/illness/episodes/[id]/restore`, undo toast in
* `src/components/illness/episode-menu.tsx`
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const MESSAGES = join(__dirname, "../../messages");
const LOCALES = ["de", "en", "es", "fr", "it", "pl"] as const;
function bundle(locale: string): Record<string, unknown> {
return JSON.parse(readFileSync(join(MESSAGES, `${locale}.json`), "utf8"));
}
function resolve(obj: Record<string, unknown>, path: string): string {
let cur: unknown = obj;
for (const seg of path.split(".")) {
expect(cur, `${path} is missing at "${seg}"`).toBeTypeOf("object");
cur = (cur as Record<string, unknown>)[seg];
}
expect(typeof cur, `${path} must resolve to a string`).toBe("string");
return cur as string;
}
/**
* Per-locale phrases that assert a deletion cannot be taken back. A dialog on
* an undo-able surface must contain none of these.
*/
const FINALITY_PHRASES: Record<string, readonly string[]> = {
en: ["cannot be undone", "can't be undone", "permanently", "permanent"],
de: ["nicht rückgängig", "endgültig", "dauerhaft", "unwiderruflich"],
es: ["no se puede deshacer", "permanente", "irreversible"],
fr: [
"irréversible",
"définitivement",
"définitive",
"ne peut pas être annulé",
],
it: [
"non può essere annullata",
"definitivamente",
"definitiva",
"permanente",
],
pl: ["nie można cofnąć", "trwale", "nieodwracaln"],
};
/** Per-locale phrase that promises the short undo window. */
const UNDO_PHRASES: Record<string, string> = {
en: "undo",
de: "rückgängig",
es: "deshacerlo",
fr: "annuler cette action",
it: "annullare",
pl: "cofnąć",
};
/** Confirmation bodies whose surface renders an Undo affordance. */
const UNDOABLE_CONFIRM_KEYS = [
"measurements.deleteConfirmDescription",
"measurements.bulkDeleteConfirmBody",
"mood.deleteConfirmDescription",
"mood.bulkDeleteConfirmBody",
"labs.deleteConfirmDescription",
"illness.deleteConfirm.body",
] as const;
/**
* Confirmation bodies whose delete really is unrecoverable for the user —
* hard delete or a tombstone with no restore path. These MUST keep a
* finality warning; softening them would be the mirror-image bug.
*/
const PERMANENT_CONFIRM_KEYS = [
// hard delete + cascade of every recorded value
"labs.biomarker.deleteConfirmDescription",
// `?purge=true` hard-deletes the tag off every past entry
"mood.manage.purgeBody",
// GDPR Art. 17 account purge
"settings.deleteAccountConfirmDescription",
] as const;
describe("delete confirmations describe what the surface actually does", () => {
for (const locale of LOCALES) {
const msgs = bundle(locale);
describe(locale, () => {
for (const key of UNDOABLE_CONFIRM_KEYS) {
it(`${key} offers undo and claims no finality`, () => {
const copy = resolve(msgs, key).toLowerCase();
for (const phrase of FINALITY_PHRASES[locale]) {
expect(
copy,
`${locale}/${key} claims finality ("${phrase}") but the surface renders an Undo`,
).not.toContain(phrase.toLowerCase());
}
expect(
copy,
`${locale}/${key} should tell the user the delete can be undone`,
).toContain(UNDO_PHRASES[locale].toLowerCase());
});
}
for (const key of PERMANENT_CONFIRM_KEYS) {
it(`${key} keeps its finality warning`, () => {
const copy = resolve(msgs, key).toLowerCase();
const warns = FINALITY_PHRASES[locale].some((p) =>
copy.includes(p.toLowerCase()),
);
expect(
warns,
`${locale}/${key} deletes irrecoverably — the copy must say so`,
).toBe(true);
});
}
});
}
});
describe("sleep debt is labelled as a balance, not a summed deficit", () => {
/**
* The figure is a running balance: a short night adds, a long night pays it
* down. Deficit/shortfall vocabulary describes the pre-v1.19.0 sum.
*/
const STALE_SUM_VOCABULARY: Record<string, readonly string[]> = {
en: ["cumulative", "shortfall", "accumulated"],
de: ["kumuliert", "defizit", "aufgelaufen"],
es: ["acumulado", "acumulada", "déficit"],
fr: ["cumulé", "cumulée", "déficit"],
it: ["cumulato", "cumulata", "accumulato", "deficit"],
pl: ["skumulowan", "niedobór", "niedoboru"],
};
for (const locale of LOCALES) {
const msgs = bundle(locale);
it(`${locale} debt captions carry no summed-deficit wording`, () => {
for (const key of [
"insights.sleep.debt.caption",
"insights.sleep.debt.clearCaption",
]) {
const copy = resolve(msgs, key).toLowerCase();
for (const term of STALE_SUM_VOCABULARY[locale]) {
expect(
copy,
`${locale}/${key} still describes the pre-v1.19.0 summed shortfall ("${term}")`,
).not.toContain(term);
}
}
});
}
it("the explainer states that catch-up sleep pays the balance down", () => {
const copy = resolve(bundle("en"), "insights.sleep.debt.computedInfo");
expect(copy.toLowerCase()).toContain("balance");
expect(copy.toLowerCase()).toContain("catch-up");
});
});
describe("the glucose reference band control is translated everywhere", () => {
const KEYS = [
"settings.glucoseReference.title",
"settings.glucoseReference.description",
"settings.glucoseReference.enable",
"settings.glucoseReference.explainer",
"settings.glucoseReference.disclaimer",
"settings.glucoseReference.error",
] as const;
for (const locale of LOCALES) {
it(`${locale} carries every key`, () => {
const msgs = bundle(locale);
for (const key of KEYS) {
expect(resolve(msgs, key).length).toBeGreaterThan(0);
}
});
}
it("the disclaimer refuses the diagnosis reading (EN)", () => {
const copy = resolve(
bundle("en"),
"settings.glucoseReference.disclaimer",
).toLowerCase();
expect(copy).toContain("not a diagnosis");
expect(copy).toContain("never inferred");
});
});
@@ -81,6 +81,8 @@ const LEGIT_IDENTICAL = new Set<string>([
// 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",
@@ -0,0 +1,82 @@
/**
* `GET /api/auth/passkeys` — the passkey management list.
*
* The management UI renders a "last used" column per credential, and a
* passkey that reads "never used" is something a person acts on: they may
* delete a working credential believing it is dead. So the field is part of
* the route's contract, not an incidental select — dropping it from the
* projection would silently turn every row into "never used" without any
* type or lint failure.
*
* The write half (the stamp on a verified assertion) is pinned in
* `src/lib/auth/__tests__/passkey.test.ts`.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/api-handler", () => ({
apiHandler: (fn: unknown) => fn,
requireAuth: vi.fn(),
}));
vi.mock("@/lib/db", () => ({
prisma: { passkey: { findMany: vi.fn() } },
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
import { GET } from "../route";
import { requireAuth } from "@/lib/api-handler";
import { prisma } from "@/lib/db";
const USER = { id: "user-1", username: "u", email: "u@example.com" };
const ROW = {
id: "pk-1",
name: "Phone",
credentialDeviceType: "multiDevice",
credentialBackedUp: true,
createdAt: new Date("2026-01-01T00:00:00Z"),
lastUsedAt: new Date("2026-07-01T10:00:00Z"),
};
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(requireAuth).mockResolvedValue({ user: USER } as never);
vi.mocked(prisma.passkey.findMany).mockResolvedValue([ROW] as never);
});
describe("GET /api/auth/passkeys", () => {
it("selects lastUsedAt, scoped to the calling user", async () => {
await (GET as unknown as () => Promise<Response>)();
expect(prisma.passkey.findMany).toHaveBeenCalledTimes(1);
const args = vi.mocked(prisma.passkey.findMany).mock.calls[0][0];
expect(args?.where).toEqual({ userId: USER.id });
expect(args?.select).toMatchObject({ lastUsedAt: true });
});
it("emits lastUsedAt in the response envelope", async () => {
const res = await (GET as unknown as () => Promise<Response>)();
const body = await res.json();
expect(body.error).toBeNull();
expect(body.data).toHaveLength(1);
expect(body.data[0]).toHaveProperty("lastUsedAt");
expect(new Date(body.data[0].lastUsedAt).toISOString()).toBe(
"2026-07-01T10:00:00.000Z",
);
});
it("passes a never-used credential through as null, not as a missing key", async () => {
vi.mocked(prisma.passkey.findMany).mockResolvedValue([
{ ...ROW, lastUsedAt: null },
] as never);
const res = await (GET as unknown as () => Promise<Response>)();
const body = await res.json();
expect(Object.keys(body.data[0])).toContain("lastUsedAt");
expect(body.data[0].lastUsedAt).toBeNull();
});
});
@@ -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.
@@ -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<Record<string, unknown>> {
return (body as { data: { metrics: Array<Record<string, unknown>> } }).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,
);
});
});
+87 -2
View File
@@ -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<MetricKind, string> = {
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<MetricKind, string> = {
@@ -209,6 +216,8 @@ const METRIC_UNIT_KEYS: Record<MetricKind, string> = {
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.
@@ -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);
}
});
});
+31 -37
View File
@@ -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<DashboardLayout>;
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 },
@@ -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<string, unknown>;
};
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<string, unknown>;
};
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<string, unknown>;
};
expect(arg.data.manufacturer).toBeNull();
expect("doseStrength" in arg.data).toBe(false);
});
});
@@ -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,
},
});
@@ -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,
}),
});
});
});
@@ -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,
}),
});
+2 -1
View File
@@ -5,7 +5,8 @@
* signals the Sleep page + the iOS client render off the SAME canonical night
* reconstruction the Sleep Score uses:
*
* - `sleepDebt` — cumulative deficit over the rolling window, with a calm
* - `sleepDebt` — outstanding balance over the rolling window (surplus
* sleep pays it down), with a calm
* `partial` state under the night threshold.
* - `chronotype` — MCTQ MSF / MSFsc band + social jetlag, with a `learning`
* state until enough free-day nights exist.
@@ -22,8 +22,11 @@ function hoursMinutes(totalMinutes: number): {
/**
* v1.17.0 — sleep-debt headline.
*
* The cumulative shortfall between sleep need and sleep obtained over the
* rolling window. Under the night threshold it shows the calm `partial`
* The outstanding balance between sleep need and sleep obtained over the
* rolling window — a night below need adds to it, a night above need pays it
* down (v1.19.0 replaced the summed shortfall with this running balance; the
* copy follows the arithmetic). Under the night threshold it shows the calm
* `partial`
* state ("still learning — N more nights") and never asserts a total off thin
* data. Mobile-first: a single readable figure + one line of grounded context.
*/
@@ -492,6 +492,11 @@ export function AddInventoryDialog({
const [containerType, setContainerType] =
useState<ContainerType>(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}
/>
</div>
{containerType === "PEN" && (
<>
<div className="space-y-2">
<label
htmlFor="inventory-add-manufacturer"
className="text-sm font-medium"
>
{t("medications.detail.bestand.addManufacturerLabel")}
</label>
<Input
id="inventory-add-manufacturer"
value={manufacturer}
maxLength={120}
onChange={(e) => setManufacturer(e.target.value)}
/>
</div>
<div className="space-y-2">
<label
htmlFor="inventory-add-dose-strength"
className="text-sm font-medium"
>
{t("medications.detail.bestand.addDoseStrengthLabel")}
</label>
<Input
id="inventory-add-dose-strength"
value={doseStrength}
maxLength={60}
onChange={(e) => setDoseStrength(e.target.value)}
/>
<p className="text-muted-foreground text-sm">
{t("medications.detail.bestand.addDoseStrengthHint")}
</p>
</div>
</>
)}
</form>
</ResponsiveSheet>
);
@@ -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);
});
});
}
});
@@ -267,6 +267,21 @@ describe("settings sections — SSR smoke", () => {
expect(html).not.toContain("settings.sections.thresholds.");
});
it("<ThresholdsSection> carries the glucose reference band control", () => {
// The declared diabetes opt-in (`PATCH /api/auth/me/diabetes`) selects the
// tighter glucose band and grounds the Coach, but shipped with no web
// control at all — a web-only account could never reach it. It belongs on
// Targets: same question ("which range judges my reading") as the editor
// below it, and the copy must stay honest about being a reference band
// rather than a diagnosis.
const html = renderFramed("thresholds", <ThresholdsSection />);
expect(html).toContain("Glucose reference band");
expect(html).toContain("Use the diabetes goal band");
expect(html).toContain("not a diagnosis");
expect(html).toContain('data-slot="glucose-reference-disclaimer"');
expect(html).not.toContain("settings.glucoseReference.");
});
it("<ThresholdsSection> resolves the German title", () => {
const html = renderFramed("thresholds", <ThresholdsSection />, "de");
// v1.8.7.1 — German title is "Zielwerte" (Targets only).
@@ -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")}
</Button>
)}
<Button
type="button"
<ConfirmButton
slot="coach-reminder-delete"
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-destructive min-h-9"
disabled={!isAuthenticated || busy}
data-testid="settings-coach-reminder-delete"
aria-label={t("settings.ai.coachReminders.deleteAria")}
onClick={() => remove.mutate(r.id)}
>
{busy ? (
<Loader2
className="size-3.5 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : (
<Trash2 className="size-3.5" aria-hidden />
)}
</Button>
disabled={!isAuthenticated}
pending={busy}
ariaLabel={t("settings.ai.coachReminders.deleteAria")}
label=""
icon={<Trash2 className="size-3.5" aria-hidden />}
title={t("settings.ai.coachReminders.deleteConfirmTitle")}
body={t("settings.ai.coachReminders.deleteConfirmBody")}
confirmLabel={t("settings.ai.coachReminders.deleteConfirmAction")}
onConfirm={() => remove.mutate(r.id)}
/>
</div>
</li>
);
+119
View File
@@ -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 (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant={variant}
size={size}
className={className}
aria-label={ariaLabel}
data-slot={slot}
disabled={disabled || pending}
>
{pending ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : (
icon
)}
{label}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{body}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
data-slot={slot ? `${slot}-confirm` : undefined}
disabled={pending}
aria-busy={pending || undefined}
onClick={onConfirm}
>
{pending && (
<Loader2 className="mr-1 size-3.5 animate-spin motion-reduce:animate-none" />
)}
{confirmLabel}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -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() {
<span className="text-sm">
{tr.label} · {tr.startDate} – {tr.endDate}
</span>
<Button
type="button"
<ConfirmButton
slot="travel-remove"
size="icon"
variant="ghost"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
aria-label={t(
ariaLabel={t("settings.sections.environment.travel.remove")}
label=""
icon={<Trash2 className="size-4" aria-hidden />}
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)}
>
<Trash2 className="size-4" />
</Button>
onConfirm={() => removeTravel.mutate(tr.id)}
/>
</li>
))}
</ul>
@@ -0,0 +1,110 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Droplet, Info } from "lucide-react";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet, apiPatch } from "@/lib/api/api-fetch";
/**
* Glucose reference band — the web control for the declared diabetes opt-in
* (`GET/PATCH /api/auth/me/diabetes`).
*
* WHY IT LIVES ON TARGETS
* -----------------------
* The flag answers exactly the question this page answers — "which range is my
* reading judged against" — and the page already owns the four glucose target
* rows. Putting it anywhere else would split one concept across two surfaces.
* The card sits ABOVE the per-metric editor because the two controls are
* ordered: this one picks the default band, a per-metric override below then
* beats it (`src/lib/targets/glucose-targets.ts`).
*
* WHAT IT DOES NOT DO
* -------------------
* The flag is a display preference, never a diagnosis, and is never inferred
* from a reading. It selects the band the glucose surfaces and the Coach's
* reference grounding read; the app takes no clinical action on it. The copy
* says so at the point of the switch rather than in a help page.
*
* Note: `GET /api/user/thresholds` (the editor below) reports the general
* bands regardless of this flag — the flag resolves in the targets pipeline
* (`src/lib/targets/build-response.ts`), which is why the mutation invalidates
* `insightsTargets()` rather than `userThresholds()`.
*/
interface DiabetesPrefShape {
hasDiabetes: boolean;
}
export function GlucoseReferenceCard() {
const { t } = useTranslations();
const queryClient = useQueryClient();
const { data } = useQuery({
queryKey: queryKeys.diabetesPref(),
queryFn: async () => apiGet<DiabetesPrefShape>("/api/auth/me/diabetes"),
});
const toggle = useMutation({
mutationFn: async (hasDiabetes: boolean) =>
apiPatch<DiabetesPrefShape>("/api/auth/me/diabetes", { hasDiabetes }),
onSuccess: (next) => {
queryClient.setQueryData(queryKeys.diabetesPref(), next);
void queryClient.invalidateQueries({
queryKey: queryKeys.insightsTargets(),
});
},
});
const enabled = data?.hasDiabetes ?? false;
return (
<SettingsCard>
<SettingsCardHeader
icon={Droplet}
title={t("settings.glucoseReference.title")}
description={t("settings.glucoseReference.description")}
/>
<div className="mt-4 space-y-4 pl-7">
<div className="flex items-start justify-between gap-4">
<Label htmlFor="glucose-reference-diabetes" className="text-sm">
{t("settings.glucoseReference.enable")}
</Label>
<Switch
id="glucose-reference-diabetes"
checked={enabled}
disabled={toggle.isPending || data == null}
onCheckedChange={(v) => toggle.mutate(v)}
className="mt-0.5 shrink-0"
/>
</div>
<p className="text-sm">{t("settings.glucoseReference.explainer")}</p>
<div
data-slot="glucose-reference-disclaimer"
className="border-border bg-muted/40 flex items-start gap-2.5 rounded-lg border-l-2 p-3"
>
<Info
className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0"
aria-hidden="true"
/>
<p className="text-foreground/80 text-xs leading-relaxed">
{t("settings.glucoseReference.disclaimer")}
</p>
</div>
{toggle.isError ? (
<p className="text-destructive text-sm" role="alert">
{t("settings.glucoseReference.error")}
</p>
) : null}
</div>
</SettingsCard>
);
}
@@ -13,8 +13,8 @@ import { useId, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { ChevronDown, Loader2, MonitorSmartphone } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsCard } from "@/components/settings/settings-card";
import { ConfirmButton } from "@/components/settings/confirm-button";
import { EmptyState } from "@/components/ui/empty-state";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -168,16 +168,19 @@ export function SecuritySessionsCard({
)}
</div>
{!s.isCurrent && (
<Button
type="button"
variant="outline"
<ConfirmButton
slot="revoke-session"
size="sm"
className="min-h-9 shrink-0"
onClick={() => revokeOne.mutate(s.id)}
disabled={revokeOne.isPending}
>
{t("settings.security.revokeSession")}
</Button>
label={t("settings.security.revokeSession")}
title={t("settings.security.revokeSessionConfirmTitle")}
body={t("settings.security.revokeSessionConfirmBody", {
device: s.device,
})}
confirmLabel={t("settings.security.revokeSession")}
onConfirm={() => revokeOne.mutate(s.id)}
pending={revokeOne.isPending}
/>
)}
</li>
))}
@@ -186,18 +189,16 @@ export function SecuritySessionsCard({
{hasOthers && (
<div className="flex justify-end">
<Button
type="button"
variant="outline"
<ConfirmButton
slot="sign-out-everywhere"
className="min-h-11 sm:min-h-9"
onClick={() => revokeOthers.mutate()}
disabled={revokeOthers.isPending}
>
{revokeOthers.isPending && (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("settings.security.signOutEverywhere")}
</Button>
label={t("settings.security.signOutEverywhere")}
title={t("settings.security.signOutEverywhereConfirmTitle")}
body={t("settings.security.signOutEverywhereConfirmBody")}
confirmLabel={t("settings.security.signOutEverywhere")}
onConfirm={() => revokeOthers.mutate()}
pending={revokeOthers.isPending}
/>
</div>
)}
+11 -2
View File
@@ -1,5 +1,6 @@
"use client";
import { GlucoseReferenceCard } from "@/components/settings/glucose-reference-card";
import { ThresholdsEditorSection } from "@/components/settings/thresholds-editor-section";
/**
@@ -31,6 +32,14 @@ import { ThresholdsEditorSection } from "@/components/settings/thresholds-editor
* the names stop clashing.
*/
export function ThresholdsSection() {
// Per-metric threshold ranges; the heading is supplied by the route frame.
return <ThresholdsEditorSection id="thresholds" />;
// The glucose reference band sits ABOVE the per-metric editor: it picks the
// DEFAULT band a glucose reading is judged against, and a per-metric
// override in the editor below then beats it. Same question, one page —
// splitting the two across surfaces would hide the precedence.
return (
<div className="space-y-4">
<GlucoseReferenceCard />
<ThresholdsEditorSection id="thresholds" />
</div>
);
}
@@ -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({
})}
</p>
</div>
<Button
type="button"
variant="outline"
<ConfirmButton
slot="revoke-trusted-device"
size="sm"
className="min-h-9 shrink-0"
onClick={() => revokeOne.mutate(d.id)}
disabled={revokeOne.isPending}
>
{t("settings.security.trustedDevices.revoke")}
</Button>
label={t("settings.security.trustedDevices.revoke")}
title={t(
"settings.security.trustedDevices.revokeConfirmTitle",
)}
body={t("settings.security.trustedDevices.revokeConfirmBody")}
confirmLabel={t("settings.security.trustedDevices.revoke")}
onConfirm={() => revokeOne.mutate(d.id)}
pending={revokeOne.isPending}
/>
</li>
))}
</ul>
@@ -147,18 +150,18 @@ export function TrustedDevicesCard({
{devices.length > 0 && (
<div className="flex justify-end">
<Button
type="button"
variant="outline"
<ConfirmButton
slot="revoke-all-trusted-devices"
className="min-h-11 sm:min-h-9"
onClick={() => revokeAll.mutate()}
disabled={revokeAll.isPending}
>
{revokeAll.isPending && (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
label={t("settings.security.trustedDevices.revokeAll")}
title={t(
"settings.security.trustedDevices.revokeAllConfirmTitle",
)}
{t("settings.security.trustedDevices.revokeAll")}
</Button>
body={t("settings.security.trustedDevices.revokeAllConfirmBody")}
confirmLabel={t("settings.security.trustedDevices.revokeAll")}
onConfirm={() => revokeAll.mutate()}
pending={revokeAll.isPending}
/>
</div>
)}
+181 -4
View File
@@ -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<DashboardLayout>` 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<DashboardLayout> = {
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<keyof DashboardLayout, …>`; 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<DashboardLayout> = { ...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<DashboardLayout> = {
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<DashboardLayout> = { ...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<DashboardLayout> = {
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<DashboardLayout> = {
version: 1,
widgets: fullyPopulatedLayout.widgets,
};
const merged = mergePreservedLayoutFields(incoming, stored);
expect(merged.widgets).toEqual(fullyPopulatedLayout.widgets);
});
});
@@ -202,7 +202,7 @@ describe("getCoachSystemPrompt — DE", () => {
// German coach can cite glucose + duration values with a valid token.
expect(prompt).toMatch(/mg\/dL/);
expect(prompt).toMatch(/mmol\/L/);
expect(prompt).toMatch(/min[\s\S]*\(Schlafdefizit/);
expect(prompt).toMatch(/min[\s\S]*\(Schlafschuld/);
});
it("carries the v1.21.2 narrative-presence clauses (A4/A5/A6/A7)", () => {
@@ -157,7 +157,7 @@ interface SleepRhythmBlockContext {
* ── v1.17.0 sleep-rhythm block (sleep-debt + chronotype) ──────────
*
* The two server-authoritative timing signals the Sleep page + the
* dashboard summary render — cumulative sleep debt and the MCTQ
* dashboard summary render — the outstanding sleep-debt balance and the MCTQ
* chronotype band + social jetlag. Built from the SAME assembler the
* dashboard route uses (`reconstructNights` → `computeSleepRhythmFromNights`),
* over the rhythm's OWN fixed trailing-42-day rows, so the coach quotes the
+4 -3
View File
@@ -361,8 +361,9 @@ ISO-week means.
timing signals — the SAME values the Sleep page and dashboard show, so
cite them as the user sees them and never recompute. It holds:
- "sleepDebt": { state, debtMinutes, needMinutes }. "debtMinutes" is
the cumulative deficit against the age-based nightly need over the
trailing window, "needMinutes" the target per night — both in
the OUTSTANDING BALANCE against the age-based nightly need over the
trailing window — a short night adds to it, a long night pays it
down, and it floors at 0 — "needMinutes" the target per night — both in
MINUTES (convert to hours/minutes when you speak, e.g. 320 → "5h
20m of sleep debt"). When "state" is "partial" the window is still
short: treat the figure as still calibrating, not a firm number.
@@ -980,7 +981,7 @@ Regeln:
- "value" ist ein vorformatierter Anzeigestring ("138/85", "84,2",
"4,1"). Stimmung als N/5 formatieren, wenn numerisch.
- "unit" ist eines aus mmHg, kg, bpm, /5, %, mg/dL, mmol/L, min
(Schlafdefizit / sozialer Jetlag).
(Schlafschuld / sozialer Jetlag).
- "window" ist eines aus last7days, last30days, last90days, allTime;
bei Tages-Pins weglassen.
- Lass den ganzen Block weg (KEINE ---KEYVALUES---Zeile, KEINE
+40
View File
@@ -137,3 +137,43 @@ describe("verifyAuthentication — Zod boundary (L-3)", () => {
expect(out.verification.verified).toBe(true);
});
});
/**
* "Last used" is a security-page fact people act on: a passkey that reads
* "never used" invites deletion. The column is only ever written here, on a
* verified assertion, so the stamp and the counter bump must stay one write.
* The read half is pinned in
* `src/app/api/auth/passkeys/__tests__/route.test.ts`.
*/
describe("verifyAuthentication — last-used stamp", () => {
// The file-level `beforeEach` re-arms the mocks but does not clear their
// call history, so the write assertions below need a clean ledger.
beforeEach(() => {
vi.mocked(prisma.passkey.update).mockClear();
});
it("stamps lastUsedAt alongside the counter on a verified assertion", async () => {
const before = Date.now();
await verifyAuthentication("ch-1", VALID_BODY);
expect(prisma.passkey.update).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.passkey.update).mock.calls[0][0];
expect(call.where).toEqual({ id: "pk-1" });
expect(call.data?.counter).toBe(BigInt(1));
const stamped = call.data?.lastUsedAt;
expect(stamped).toBeInstanceOf(Date);
expect((stamped as Date).getTime()).toBeGreaterThanOrEqual(before);
});
it("does not stamp lastUsedAt when the assertion fails", async () => {
vi.mocked(verifyAuthenticationResponse).mockResolvedValue({
verified: false,
} as never);
await verifyAuthentication("ch-1", VALID_BODY);
expect(prisma.passkey.update).not.toHaveBeenCalled();
});
});
+99
View File
@@ -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<keyof DashboardLayout, …>` 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<keyof DashboardLayout, "replace" | "preserve">;
/**
* 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<keyof DashboardLayout>
).filter(
(field) => LAYOUT_FIELD_MERGE_DISPOSITION[field] === "preserve",
) as ReadonlyArray<keyof DashboardLayout>;
/**
* 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<DashboardLayout>,
): 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<DashboardLayout>,
existing: DashboardLayout,
): Partial<DashboardLayout> {
const merged: Partial<DashboardLayout> = { ...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<string, unknown>)[field] = existing[field];
}
}
return merged;
}
const DASHBOARD_LAYOUT_VERSION = 1;
/**
+3 -3
View File
@@ -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",
+12
View File
@@ -74,6 +74,11 @@ export const SUMMARY_TYPE_MODULE: Partial<Record<string, ModuleKey>> = {
// `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<string, string> = {
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",
};
/**
+2 -1
View File
@@ -4,7 +4,8 @@
* The one place that turns a user's raw `SLEEP_DURATION` rows into the two
* timing signals the Sleep page + the iOS client + the dashboard render:
*
* - cumulative SLEEP DEBT over the rolling window (`computeSleepDebt`), and
* - the outstanding SLEEP DEBT balance over the rolling window
* (`computeSleepDebt`; surplus sleep pays it down), and
* - MCTQ CHRONOTYPE + social jetlag (`computeChronotype`).
*
* ONE ENGINE, NO DUPLICATION
@@ -236,6 +236,21 @@ describe("tone harness — deterministic fallbacks", () => {
},
];
/**
* "No assessment is available" in each locale's own words. Keyed per locale
* rather than `de ? … : /No assessment/`, which is the same de/en binary that
* let four locales render English bodies unnoticed — under it, a French floor
* passed this assertion precisely BECAUSE it was still English.
*/
const NO_READ_SENTINEL: Record<Locale, RegExp> = {
de: /keine Einschätzung vor/,
en: /No assessment/,
fr: /Aucune évaluation/,
es: /no hay ninguna valoración/,
it: /non è disponibile alcuna valutazione/,
pl: /nie ma (?:tu )?żadnej oceny/,
};
describe.each(FLOORS)("$name floor", ({ build }) => {
it.each(locales)("%s opens on the read, not on an order", (locale) => {
const text = build(locale);
@@ -244,11 +259,33 @@ describe("tone harness — deterministic fallbacks", () => {
// The floor states plainly that no assessment is available — it must not
// dress the absence up as one.
expect(text).toMatch(
locale === "de" ? /keine Einschätzung vor/ : /No assessment/,
);
expect(text).toMatch(NO_READ_SENTINEL[locale]);
});
});
/**
* The regression this whole pass exists to close: `getLocalizedText` used to
* return `locale === "de" ? de : en`, so fr/es/it/pl rendered the ENGLISH
* body on nine metric surfaces. Every tone rule above passed on that text —
* it was in voice, it just was not the reader's language.
*
* Byte-identity against English is therefore the assertion that actually
* pins it. It holds for both the no-signal floors and the signal-grounded
* composer, because the composer has its own per-locale sentence templates
* (the value sentence and the verdict lead), not only per-locale nouns.
*/
describe.each(RIDERS)("%s renders its own body", (locale) => {
it.each(FLOORS)("$name floor is not the English text", ({ build }) => {
expect(build(locale)).not.toEqual(build("en"));
});
it.each(GROUNDED)(
"$name grounded line is not the English text",
({ build }) => {
expect(build(locale)).not.toEqual(build("en"));
},
);
});
});
describe("tone harness — the rules themselves", () => {
+40 -7
View File
@@ -241,17 +241,45 @@ export function checkLocaleIntegrity(input: {
// ── deterministic fallback tone ─────────────────────────────────────────────
/** Praise that the numbers did not earn. */
/**
* Praise that the numbers did not earn.
*
* Six locales, because the deterministic fallbacks now carry a written body in
* each: a rule that only knows English and German praise would grade four
* locales as clean no matter what they said. Vocabulary only — the rule shape
* is language-independent.
*/
const FALSE_CHEER =
/\b(great job|well done|amazing|fantastic|excellent|awesome|congratulations|keep it up|don't worry)\b|\b(super gemacht|toll gemacht|großartig|fantastisch|hervorragend|weiter so|keine sorge)\b/i;
/\b(great job|well done|amazing|fantastic|excellent|awesome|congratulations|keep it up|don't worry)\b|\b(super gemacht|toll gemacht|großartig|fantastisch|hervorragend|weiter so|keine sorge)\b|\b(bravo|félicitations|continue comme ça|génial|formidable|ne t['’]inquiète pas)\b|\b(enhorabuena|felicidades|sigue así|genial|estupendo|no te preocupes)\b|\b(complimenti|continua così|ottimo lavoro|fantastico|eccellente|non preoccuparti)\b|\b(gratulacje|brawo|tak trzymaj|świetnie|wspaniale|nie martw się)\b/i;
/** Verdict vocabulary a non-diagnostic surface may never use. */
/**
* Verdict vocabulary a non-diagnostic surface may never use.
*
* Note what is deliberately NOT here: the plain noun for "verdict" in each
* language (verdict / veredicto / verdetto / wyrok / Urteil). Several floors
* use it in the negated form that carries the whole non-diagnostic stance — "a
* reference range is a coarse anchor, not a verdict" — so banning the noun
* would flag the sentence that states the rule. The ban targets diagnosis,
* pathology, attribution of a condition to the reader, and treatment
* direction, which is what a non-diagnostic surface actually may not do.
*/
const CLINICAL_VERDICT =
/\b(diagnos\w*|abnormal|patholog\w*|you (?:have|suffer from)|risk score|clinically significant)\b|\b(Diagnose|krankhaft|pathologisch|du (?:hast|leidest)|behandlungsbedürftig)\b/i;
/\b(diagnos\w*|abnormal|patholog\w*|you (?:have|suffer from)|risk score|clinically significant)\b|\b(Diagnose|krankhaft|pathologisch|du (?:hast|leidest)|behandlungsbedürftig)\b|\b(diagnostiqu\w*|pathologique|tu souffres de|nécessite un traitement)\b|\b(diagnóstic\w*|patológic\w*|sufres de|requiere (?:un )?tratamiento)\b|\b(diagnosi|diagnostic\w*|patologic\w*|soffri di|richiede (?:un )?trattamento)\b|\b(diagnoz\w*|patologiczn\w*|cierpisz na|wymaga leczenia)\b/i;
/** The value-led opener §10 bans: "Your <metric> is <number>…". */
/**
* The value-led opener §10 bans: "Your <metric> is <number>…".
*
* Rewritten from the de/en-only shape, which assumed the copula sits directly
* against the figure. That is true for "Your BMI is 24" and false for almost
* everything else the surfaces actually render — German "liegt aktuell bei
* 24", French "est à 24", Spanish "está en 24", Polish "wynosi obecnie 24".
* The old pattern therefore under-matched German, its own second locale, and
* would have missed all four new ones. The shape is now: possessive determiner,
* up to three words of metric name, the copula, then up to two positional
* fillers before the figure.
*/
const VALUE_LED_OPENER =
/^(Your|Dein|Deine|Ihr|Ihre)\s+\S+(\s+\S+)?\s+(is|ist|liegt)\s+\d/i;
/^(?:Your|Dein(?:e)?|Ihr(?:e)?|Ta|Ton|Tes|Tu|Tus|La tua|Le tue|Il tuo|I tuoi|Twój|Twoja|Twoje)\b(?:\s+\S+){0,3}\s+(?:is|ist|liegt|est|está|è|wynosi)\b(?:\s+(?:at|bei|à|a|en|aktuell|obecnie|right now)){0,2}\s+\d/i;
/**
* The OTHER way a deterministic line stops leading with meaning: it opens on a
@@ -263,9 +291,14 @@ const VALUE_LED_OPENER =
* failure: the surface tells the reader what to do before it says anything
* about what it can and cannot see. A pointer is fine LATER in the line; it
* may not be the opening move.
*
* Extended to the four locales whose bodies used to be English. Romance and
* Polish imperatives are a closed enough set here because the pointers these
* floors could regress into are drawn from the same small verb family the
* de/en list already names — measure, weigh, track, compare, read.
*/
const IMPERATIVE_OPENER =
/^(Measure|Track|Evaluate|Monitor|Check|Watch|Log|React|Use|Keep|Pay|Consider|Make sure)\b|^(Miss|Bewerte|Beobachte|Achte|Nutze|Halte|Reagiere|Prüfe|Vergleiche|Erfasse)\b/;
/^(Measure|Track|Evaluate|Monitor|Check|Watch|Log|React|Use|Keep|Pay|Consider|Make sure)\b|^(Miss|Bewerte|Beobachte|Achte|Nutze|Halte|Reagiere|Prüfe|Vergleiche|Erfasse)\b|^(Mesure|Suis|Évalue|Surveille|Vérifie|Note|Utilise|Garde|Compare|Pèse|Prends|Lis)\b|^(Mide|Evalúa|Vigila|Comprueba|Registra|Usa|Mantén|Compara|Pésate|Toma|Lee)\b|^(Misura|Valuta|Monitora|Controlla|Registra|Usa|Mantieni|Confronta|Pesati|Prendi|Leggi)\b|^(Mierz|Oceń|Obserwuj|Sprawdź|Zapisuj|Używaj|Utrzymuj|Porównaj|Waż|Czytaj)\b/;
function firstSentence(text: string): string {
const m = /^[^.!?]*[.!?]/.exec(text.trim());
+339 -118
View File
@@ -2,12 +2,19 @@ import type { Locale } from "@/lib/i18n/config";
import type { MetricSignal } from "@/lib/insights/metric-signal";
/**
* v1.4.25 W9e — these no-key fallbacks ship DE + EN bodies only. The
* type accepts every shipped locale (fr/es/it/pl too) and routes
* non-DE locales through the EN body, mirroring the same fallback
* chain the JSON message bundles use. When DE/EN-only fallbacks get
* replaced with proper FR/ES/IT/PL bodies, expand `getLocalizedText`
* to read the matching argument.
* v1.4.25 W9e / v1.31.0 — these no-key fallbacks now carry a written body for
* every shipped locale (de/en/fr/es/it/pl). They used to ship DE + EN only and
* route the other four through the English body, which meant a French, Spanish,
* Italian or Polish self-hoster read English on nine metric surfaces whenever
* no provider was reachable. `pick` selects the reader's own body; English
* stays as the structural floor for a locale that is ever genuinely missing
* one, not as the shipped answer for four of six.
*
* The bodies are written per language rather than translated token-for-token.
* Where a sentence cannot carry the English shape — a comparative adjective
* that would have to agree with the metric noun's gender, a locative "is at"
* with no natural equivalent — that language's sentence is restructured. The
* per-language notes sit at the templates themselves.
*
* v1.21.0 (coach C1 HIGH-1/2) — these surfaces used to emit static
* clinical tips with ZERO reference to the user's own numbers, and the
@@ -22,12 +29,24 @@ import type { MetricSignal } from "@/lib/insights/metric-signal";
*/
export type InsightLocale = Locale;
/**
* A per-locale table with English required.
*
* English is required so the fallback arm can never itself be undefined; every
* other locale is optional so a genuinely missing body degrades to English
* rather than to `undefined`. Every table in this file fills all six.
*/
type Localized<T> = { en: T } & Partial<Record<InsightLocale, T>>;
function pick<T>(locale: InsightLocale, table: Localized<T>): T {
return table[locale] ?? table.en;
}
function getLocalizedText(
locale: InsightLocale,
de: string,
en: string,
text: Localized<string>,
): string {
return locale === "de" ? de : en;
return pick(locale, text);
}
// ── signal-grounded composer ────────────────────────────────────────────────
@@ -35,7 +54,7 @@ function getLocalizedText(
/** A natural-language metric label + the pointer phrasing for one metric. */
interface FallbackCopy {
/** Possessive natural-language label ("your blood pressure"). */
label: { de: string; en: string };
label: Localized<string>;
/** Unit suffix appended to a value (" bpm"), empty when none. */
unit?: string;
/** Digits to round the rendered value to (0 for integers). */
@@ -44,7 +63,7 @@ interface FallbackCopy {
* One grounded pointer used when the value sits outside the user's own
* usual swing — framed as an opportunity, never an order.
*/
pointer: { de: string; en: string };
pointer: Localized<string>;
}
function fmtValue(value: number, copy: FallbackCopy): string {
@@ -73,15 +92,40 @@ function capitalise(s: string): string {
* signal is too thin to state a confident read (no baseline / no delta), in
* which case the line simply opens on the value as before.
*/
const VERDICT_STEADY: Localized<string> = {
de: "Stabil und wie gewohnt",
en: "Steady and much as usual",
fr: "Stable, comme d’habitude",
es: "Estable, como de costumbre",
it: "Stabile, come al solito",
pl: "Stabilnie, tak jak zwykle",
};
const VERDICT_FAVOURABLE: Localized<string> = {
de: "Das geht in eine gute Richtung",
en: "This is moving in a good direction",
fr: "Cela évolue dans le bon sens",
es: "Esto va en buena dirección",
it: "Sta andando nella direzione giusta",
pl: "To zmierza w dobrym kierunku",
};
const VERDICT_OFF_USUAL: Localized<string> = {
de: "Zuletzt etwas außerhalb deines üblichen Rahmens",
en: "A little off your usual lately",
fr: "Un peu en dehors de tes repères habituels ces derniers temps",
es: "Últimamente algo fuera de tu rango habitual",
it: "Ultimamente un po’ fuori dal tuo intervallo abituale",
pl: "Ostatnio nieco poza twoim zwykłym zakresem",
};
function verdictLead(
signal: MetricSignal,
locale: InsightLocale,
): string | null {
// Inside the user's own usual swing → a steady, reassuring verdict.
if (signal.outsideNormalSwing === false) {
return locale === "de"
? "Stabil und wie gewohnt"
: "Steady and much as usual";
return pick(locale, VERDICT_STEADY);
}
// Outside the usual swing → is the move in a favourable or unfavourable
// direction? For a target-band metric the direction alone can't say, so it
@@ -98,18 +142,93 @@ function verdictLead(
? signal.delta < 0
: null;
if (favourable === true) {
return locale === "de"
? "Das geht in eine gute Richtung"
: "This is moving in a good direction";
return pick(locale, VERDICT_FAVOURABLE);
}
return locale === "de"
? "Zuletzt etwas außerhalb deines üblichen Rahmens"
: "A little off your usual lately";
return pick(locale, VERDICT_OFF_USUAL);
}
// No baseline / no usable delta → no confident verdict; open on the value.
return null;
}
/**
* The value sentence, placed against the user's own baseline.
*
* Three shapes, one per grounding level. Each is a per-locale template rather
* than one template with swapped words, because the languages do not share a
* sentence shape here:
*
* · de/en keep the locative "liegt bei" / "is at" plus a comparative adjective.
* · fr/es/it drop the comparative ("plus haut", "más alto", "più alto") for a
* PREPOSITION — "au-dessus de", "por encima de", "sopra". The comparative
* would have to agree in gender with the metric noun, which differs across
* the labels (ta tension / ton poids, tu presión / tu peso, la tua pressione
* / il tuo peso), so a single template carrying an adjective cannot be
* correct for all of them. The preposition governs the noun without agreeing
* with it, which makes one template safe for every metric.
* · pl restructures further: there is no natural locative "is at" for a
* measurement, so the sentence takes the verb `wynosi` ("amounts to"), which
* is invariant for the subject's gender — Polish adjectives and past-tense
* verbs are not, and the labels span all three genders (twój nastrój m.,
* twoja waga f., twoje tętno n.). The comparison likewise uses the
* prepositions `powyżej` / `poniżej` with the genitive rather than a
* comparative adjective.
*/
const VALUE_VS_BASELINE: Localized<
(label: string, current: string, baseline: string, higher: boolean) => string
> = {
de: (l, c, b, higher) =>
`${l} liegt aktuell bei ${c} — ${higher ? "höher" : "niedriger"} als dein üblicher Schnitt von ${b}.`,
en: (l, c, b, higher) =>
`${l} is at ${c} right now — ${higher ? "higher" : "lower"} than your usual average of ${b}.`,
fr: (l, c, b, higher) =>
`${l} est à ${c} en ce moment — ${higher ? "au-dessus" : "en dessous"} de ta moyenne habituelle de ${b}.`,
es: (l, c, b, higher) =>
`${l} está en ${c} ahora mismo — ${higher ? "por encima" : "por debajo"} de tu promedio habitual de ${b}.`,
it: (l, c, b, higher) =>
`${l} è a ${c} in questo momento — ${higher ? "sopra" : "sotto"} la tua media abituale di ${b}.`,
pl: (l, c, b, higher) =>
`${l} wynosi obecnie ${c} — ${higher ? "powyżej" : "poniżej"} twojej zwykłej średniej ${b}.`,
};
const VALUE_AT_BASELINE: Localized<(label: string, current: string) => string> =
{
de: (l, c) =>
`${l} liegt aktuell bei ${c}, im Bereich deines üblichen Schnitts.`,
en: (l, c) => `${l} is at ${c}, right around your usual average.`,
fr: (l, c) => `${l} est à ${c}, tout près de ta moyenne habituelle.`,
es: (l, c) => `${l} está en ${c}, justo en torno a tu promedio habitual.`,
it: (l, c) => `${l} è a ${c}, più o meno sulla tua media abituale.`,
pl: (l, c) =>
`${l} wynosi obecnie ${c}, czyli mniej więcej tyle co twoja zwykła średnia.`,
};
const VALUE_ALONE: Localized<(label: string, current: string) => string> = {
de: (l, c) => `${l} liegt aktuell bei ${c}.`,
en: (l, c) => `${l} is at ${c} right now.`,
fr: (l, c) => `${l} est à ${c} en ce moment.`,
es: (l, c) => `${l} está en ${c} ahora mismo.`,
it: (l, c) => `${l} è a ${c} in questo momento.`,
pl: (l, c) => `${l} wynosi obecnie ${c}.`,
};
const CLOSER_IN_RANGE: Localized<string> = {
de: "Das ist in deinem gewohnten Rahmen — nichts, worauf du jetzt reagieren musst.",
en: "That sits in your usual range — nothing you need to act on right now.",
fr: "C’est dans tes repères habituels — rien qui appelle une réaction maintenant.",
es: "Eso queda dentro de tu rango habitual — nada que pida una reacción ahora.",
it: "Rientra nel tuo intervallo abituale — niente su cui intervenire adesso.",
pl: "Mieści się to w twoim zwykłym zakresie — nic, na co trzeba teraz reagować.",
};
const CLOSER_UNKNOWN_SWING: Localized<string> = {
de: "Ein paar Messungen mehr machen die Tendenz belastbar — beobachte sie über die nächsten Tage.",
en: "A few more readings will make the trend dependable — keep an eye on it over the coming days.",
fr: "Quelques mesures de plus rendront la tendance fiable — garde un œil dessus les prochains jours.",
es: "Unas pocas mediciones más harán fiable la tendencia — obsérvala durante los próximos días.",
it: "Qualche misurazione in più renderà la tendenza affidabile — tienila d’occhio nei prossimi giorni.",
pl: "Kilka kolejnych pomiarów sprawi, że tendencja stanie się wiarygodna — obserwuj ją przez najbliższe dni.",
};
/**
* Compose a warm, grounded deterministic line from a metric signal. Leads with
* a plain-words verdict (meaning first), then names the current value placed
@@ -125,7 +244,7 @@ function composeGroundedFallback(
): string | null {
if (!signal || !Number.isFinite(signal.current)) return null;
const label = locale === "de" ? copy.label.de : copy.label.en;
const label = capitalise(pick(locale, copy.label));
const current = fmtValue(signal.current, copy);
const sentences: string[] = [];
@@ -143,47 +262,29 @@ function composeGroundedFallback(
Number.isFinite(signal.baseline)
) {
const baseline = fmtValue(signal.baseline, copy);
const higher = signal.delta > 0;
sentences.push(
locale === "de"
? `${capitalise(label)} liegt aktuell bei ${current} — ${
higher ? "höher" : "niedriger"
} als dein üblicher Schnitt von ${baseline}.`
: `${capitalise(label)} is at ${current} right now — ${
higher ? "higher" : "lower"
} than your usual average of ${baseline}.`,
pick(locale, VALUE_VS_BASELINE)(
label,
current,
baseline,
signal.delta > 0,
),
);
} else if (signal.baseline !== null && Number.isFinite(signal.baseline)) {
sentences.push(
locale === "de"
? `${capitalise(label)} liegt aktuell bei ${current}, im Bereich deines üblichen Schnitts.`
: `${capitalise(label)} is at ${current}, right around your usual average.`,
);
sentences.push(pick(locale, VALUE_AT_BASELINE)(label, current));
} else {
// No baseline yet — name the value honestly without inventing a comparison.
sentences.push(
locale === "de"
? `${capitalise(label)} liegt aktuell bei ${current}.`
: `${capitalise(label)} is at ${current} right now.`,
);
sentences.push(pick(locale, VALUE_ALONE)(label, current));
}
// 2) One grounded pointer when outside the usual swing; otherwise affirm.
if (signal.outsideNormalSwing === true) {
sentences.push(locale === "de" ? copy.pointer.de : copy.pointer.en);
sentences.push(pick(locale, copy.pointer));
} else if (signal.outsideNormalSwing === false) {
sentences.push(
locale === "de"
? "Das ist in deinem gewohnten Rahmen — nichts, worauf du jetzt reagieren musst."
: "That sits in your usual range — nothing you need to act on right now.",
);
sentences.push(pick(locale, CLOSER_IN_RANGE));
} else {
// Unknown swing (no baseline): keep one steady, non-alarming pointer.
sentences.push(
locale === "de"
? "Ein paar Messungen mehr machen die Tendenz belastbar — beobachte sie über die nächsten Tage."
: "A few more readings will make the trend dependable — keep an eye on it over the coming days.",
);
sentences.push(pick(locale, CLOSER_UNKNOWN_SWING));
}
return sentences.join(" ");
@@ -192,65 +293,141 @@ function composeGroundedFallback(
// ── per-metric copy ─────────────────────────────────────────────────────────
const BLOOD_PRESSURE_COPY: FallbackCopy = {
label: { de: "dein Blutdruck", en: "your blood pressure" },
label: {
de: "dein Blutdruck",
en: "your blood pressure",
fr: "ta tension artérielle",
es: "tu presión arterial",
it: "la tua pressione sanguigna",
pl: "twoje ciśnienie krwi",
},
unit: "",
digits: 0,
pointer: {
de: "Ein paar ruhige Messungen unter gleichen Bedingungen zeigen, ob das die Tendenz ist oder ein Ausreißer.",
en: "A few calm readings under the same conditions will show whether this is the trend or a single outlier.",
fr: "Quelques mesures au calme dans les mêmes conditions montreront si c’est la tendance ou une valeur isolée.",
es: "Unas cuantas mediciones en calma y en las mismas condiciones mostrarán si es la tendencia o un valor aislado.",
it: "Qualche misurazione a riposo nelle stesse condizioni mostrerà se è la tendenza o un valore isolato.",
pl: "Kilka spokojnych pomiarów w tych samych warunkach pokaże, czy to tendencja, czy pojedynczy wyskok.",
},
};
const WEIGHT_COPY: FallbackCopy = {
label: { de: "dein Gewicht", en: "your weight" },
label: {
de: "dein Gewicht",
en: "your weight",
fr: "ton poids",
es: "tu peso",
it: "il tuo peso",
pl: "twoja waga",
},
unit: "",
digits: 1,
pointer: {
de: "Wäge dich ein paar Tage zur gleichen Zeit, dann ordnet sich die normale Schwankung von selbst ein.",
en: "Weigh in at the same time for a few days and the normal day-to-day swing sorts itself out.",
fr: "Pèse-toi à la même heure pendant quelques jours et la fluctuation quotidienne normale se lisse d’elle-même.",
es: "Pésate a la misma hora durante unos días y la fluctuación diaria normal se compensa sola.",
it: "Pesati alla stessa ora per qualche giorno e la normale oscillazione quotidiana si compensa da sé.",
pl: "Waż się przez kilka dni o tej samej porze, a normalne dobowe wahania same się wyrównają.",
},
};
const PULSE_COPY: FallbackCopy = {
label: { de: "dein Ruhepuls", en: "your resting pulse" },
label: {
de: "dein Ruhepuls",
en: "your resting pulse",
fr: "ton pouls au repos",
es: "tu pulso en reposo",
it: "il tuo battito a riposo",
pl: "twoje tętno spoczynkowe",
},
unit: " bpm",
digits: 0,
pointer: {
de: "Miss ihn entspannt und zur gleichen Tageszeit, dann siehst du, ob die Richtung anhält.",
en: "Take it relaxed and at the same time of day to see whether the direction holds.",
fr: "Prends-le détendu et à la même heure de la journée pour voir si la direction se maintient.",
es: "Tómatelo relajado y a la misma hora del día para ver si la dirección se mantiene.",
it: "Misuralo da rilassato e alla stessa ora del giorno per vedere se la direzione tiene.",
pl: "Mierz je na spokojnie i o tej samej porze dnia, żeby zobaczyć, czy kierunek się utrzyma.",
},
};
const BMI_COPY: FallbackCopy = {
label: { de: "dein BMI", en: "your BMI" },
label: {
de: "dein BMI",
en: "your BMI",
fr: "ton IMC",
es: "tu IMC",
it: "il tuo IMC",
pl: "twoje BMI",
},
unit: "",
digits: 1,
pointer: {
de: "Schau ihn dir zusammen mit Gewichtstrend und Körperfett über ein paar Wochen an.",
en: "Read it alongside your weight trend and body-fat over a few weeks rather than day by day.",
fr: "Lis-le avec la tendance de ton poids et ta masse grasse sur quelques semaines plutôt qu’au jour le jour.",
es: "Léelo junto con la tendencia de tu peso y tu grasa corporal a lo largo de unas semanas, no día a día.",
it: "Leggilo insieme all’andamento del tuo peso e alla massa grassa nell’arco di qualche settimana, non giorno per giorno.",
pl: "Czytaj je razem z tendencją wagi i poziomem tkanki tłuszczowej w skali kilku tygodni, a nie dzień po dniu.",
},
};
const MOOD_COPY: FallbackCopy = {
label: { de: "deine Stimmung", en: "your mood" },
label: {
de: "deine Stimmung",
en: "your mood",
fr: "ton humeur",
es: "tu estado de ánimo",
it: "il tuo umore",
pl: "twój nastrój",
},
unit: "",
digits: 1,
pointer: {
de: "Halte sie ein paar Tage fest — wiederkehrende Muster sagen mehr als ein einzelner Tag.",
en: "Log it for a few days — a recurring pattern says more than any single day.",
fr: "Note-la pendant quelques jours — un motif qui revient en dit plus qu’une journée isolée.",
es: "Regístralo unos días — un patrón que se repite dice más que un día suelto.",
it: "Registralo per qualche giorno — uno schema che si ripete dice più di un singolo giorno.",
pl: "Zapisuj go przez kilka dni — powtarzający się wzorzec mówi więcej niż pojedynczy dzień.",
},
};
const ADHERENCE_COPY: FallbackCopy = {
label: { de: "deine Einnahmetreue", en: "your medication adherence" },
label: {
de: "deine Einnahmetreue",
en: "your medication adherence",
fr: "ton observance du traitement",
es: "tu adherencia a la medicación",
it: "la tua aderenza alla terapia",
pl: "twoja regularność przyjmowania leków",
},
unit: "%",
digits: 0,
pointer: {
de: "Eine feste Zeit oder ein kurzer Reminder fängt die wiederkehrenden Auslassungen am ehesten ab.",
en: "A fixed time or a quick reminder is the most reliable way to catch the repeat misses.",
fr: "Une heure fixe ou un rappel court est ce qui rattrape le mieux les oublis répétés.",
es: "Una hora fija o un recordatorio breve es lo que mejor recupera los olvidos repetidos.",
it: "Un orario fisso o un promemoria breve è ciò che intercetta meglio le dimenticanze ripetute.",
pl: "Stała pora albo krótkie przypomnienie najlepiej wyłapuje powtarzające się pominięcia.",
},
};
/** The generic pointer for the dynamic per-metric card, which has no copy table. */
const GENERIC_METRIC_POINTER: Localized<string> = {
de: "Ein paar konsistente Messungen zeigen, ob das die Richtung ist oder ein einzelner Tag.",
en: "A few consistent readings will show whether this is the direction or a single day.",
fr: "Quelques mesures régulières montreront si c’est la direction prise ou une seule journée.",
es: "Unas cuantas mediciones constantes mostrarán si es la dirección o un solo día.",
it: "Qualche misurazione costante mostrerà se è la direzione o un singolo giorno.",
pl: "Kilka konsekwentnych pomiarów pokaże, czy to kierunek, czy pojedynczy dzień.",
};
// ── public fallbacks ────────────────────────────────────────────────────────
/**
@@ -268,16 +445,12 @@ export function getNoKeyMetricStatusText(
? composeGroundedFallback(
signal,
{
label: {
de: signal.metric,
en: signal.metric,
},
// The signal's own label is already natural language and is not
// localised upstream, so it stands for every locale.
label: { en: signal.metric },
...(signal.unit ? { unit: ` ${signal.unit}` } : {}),
digits: 1,
pointer: {
de: "Ein paar konsistente Messungen zeigen, ob das die Richtung ist oder ein einzelner Tag.",
en: "A few consistent readings will show whether this is the direction or a single day.",
},
pointer: GENERIC_METRIC_POINTER,
},
locale,
)
@@ -299,24 +472,34 @@ export function getNoKeyMetricStatusText(
* What follows the lead is what makes the metric readable, phrased as how the
* measure behaves rather than as an order to the reader.
*/
const NO_READ_LEAD: Localized<string> = {
de: "Für diese Karte liegt gerade keine Einschätzung vor.",
en: "No assessment on this one right now.",
fr: "Aucune évaluation disponible pour le moment.",
es: "Ahora mismo no hay ninguna valoración disponible.",
it: "Al momento non è disponibile alcuna valutazione.",
pl: "Na razie nie ma tu żadnej oceny.",
};
function noReadLead(locale: InsightLocale): string {
return locale === "de"
? "Für diese Karte liegt gerade keine Einschätzung vor."
: "No assessment on this one right now.";
return pick(locale, NO_READ_LEAD);
}
function floor(locale: InsightLocale, de: string, en: string): string {
return `${noReadLead(locale)} ${getLocalizedText(locale, de, en)}`;
function floor(locale: InsightLocale, body: Localized<string>): string {
return `${noReadLead(locale)} ${getLocalizedText(locale, body)}`;
}
export function getNoKeyGeneralStatusText(locale: InsightLocale): string {
// The overview spans many metrics with no single headline value to ground
// against, so it keeps the honest, generic multi-metric pointer.
return floor(
locale,
"Aussagekräftig werden diese Zahlen über mehrere Wochen, nicht über einzelne Tage — und zu konstanten Zeitpunkten erfasst, damit sie vergleichbar bleiben. Das Signal, das zählt, ist mehrere Kennzahlen, die sich gleichzeitig in dieselbe ungünstige Richtung bewegen, nicht ein Wert an einem Tag.",
"These numbers become readable across several weeks rather than single days, and taken at consistent times so they stay comparable. The signal that counts is several metrics drifting the same unfavourable way at once, not one value on one day.",
);
return floor(locale, {
de: "Aussagekräftig werden diese Zahlen über mehrere Wochen, nicht über einzelne Tage — und zu konstanten Zeitpunkten erfasst, damit sie vergleichbar bleiben. Das Signal, das zählt, ist mehrere Kennzahlen, die sich gleichzeitig in dieselbe ungünstige Richtung bewegen, nicht ein Wert an einem Tag.",
en: "These numbers become readable across several weeks rather than single days, and taken at consistent times so they stay comparable. The signal that counts is several metrics drifting the same unfavourable way at once, not one value on one day.",
fr: "Ces chiffres deviennent lisibles sur plusieurs semaines plutôt que sur des journées isolées, et relevés à des moments constants pour rester comparables. Le signal qui compte, c’est plusieurs indicateurs qui dérivent en même temps dans le même sens défavorable, pas une valeur un jour donné.",
es: "Estos números se vuelven legibles a lo largo de varias semanas y no en días sueltos, y tomados a horas constantes para que sigan siendo comparables. La señal que cuenta son varios indicadores derivando a la vez en la misma dirección desfavorable, no un valor en un día.",
it: "Questi numeri diventano leggibili nell’arco di più settimane anziché di singoli giorni, e rilevati a orari costanti per restare confrontabili. Il segnale che conta è più indicatori che scivolano insieme nella stessa direzione sfavorevole, non un valore in un giorno.",
pl: "Te liczby stają się czytelne w skali kilku tygodni, a nie pojedynczych dni, i tylko wtedy, gdy są zbierane o stałych porach, żeby pozostały porównywalne. Sygnałem, który się liczy, jest kilka wskaźników dryfujących jednocześnie w tę samą niekorzystną stronę, a nie jedna wartość jednego dnia.",
});
}
/**
@@ -324,26 +507,46 @@ export function getNoKeyGeneralStatusText(locale: InsightLocale): string {
* multi-metric overview tip — text about watching several metrics at once, on
* a card showing exactly one lab value. This says something true about a lab
* marker instead.
*
* The named lead is built per locale rather than by splicing a shared clause:
* the quoting convention differs (German low-high quotes, French and Italian
* guillemets, Polish low-high) and so does where the marker name attaches to
* the sentence.
*/
const BIOMARKER_NAMED_LEAD: Localized<(named: string) => string> = {
de: (n) => `Für diesen Laborwert${n} liegt gerade keine Einschätzung vor.`,
en: (n) => `No assessment${n} right now.`,
fr: (n) => `Aucune évaluation${n} pour le moment.`,
es: (n) => `Ahora mismo no hay ninguna valoración${n}.`,
it: (n) => `Al momento non è disponibile alcuna valutazione${n}.`,
pl: (n) => `Na razie nie ma żadnej oceny${n}.`,
};
const BIOMARKER_NAMED_CLAUSE: Localized<(marker: string) => string> = {
de: (m) => ` zu „${m}“`,
en: (m) => ` for "${m}"`,
fr: (m) => ` pour « ${m} »`,
es: (m) => ` de «${m}»`,
it: (m) => ` per «${m}»`,
pl: (m) => ` dla „${m}”`,
};
export function getNoKeyBiomarkerStatusText(
locale: InsightLocale,
markerName?: string | null,
): string {
const trimmed = markerName?.trim() ?? "";
const named =
markerName && markerName.trim().length > 0
? locale === "de"
? ` zu „${markerName.trim()}"`
: ` for "${markerName.trim()}"`
: "";
const lead =
locale === "de"
? `Für diesen Laborwert${named} liegt gerade keine Einschätzung vor.`
: `No assessment${named} right now.`;
return `${lead} ${getLocalizedText(
locale,
"Ein Laborwert liest sich zuerst gegen die eigenen vorherigen Abnahmen — ein Referenzbereich ist ein grober Anker, kein Urteil. Ein Wert, der sich über mehrere Abnahmen kaum bewegt, erzählt etwas anderes als einer, der gerade gesprungen ist. Die nächste ärztliche Kontrolle ist der richtige Ort, ihn einzuordnen.",
"A lab value reads first against your own previous draws — a reference range is a coarse anchor, not a verdict. One that has barely moved across several draws tells a different story from one that has just stepped. Your next check-up is the right place to put it in context.",
)}`;
trimmed.length > 0 ? pick(locale, BIOMARKER_NAMED_CLAUSE)(trimmed) : "";
const lead = pick(locale, BIOMARKER_NAMED_LEAD)(named);
return `${lead} ${getLocalizedText(locale, {
de: "Ein Laborwert liest sich zuerst gegen die eigenen vorherigen Abnahmen — ein Referenzbereich ist ein grober Anker, kein Urteil. Ein Wert, der sich über mehrere Abnahmen kaum bewegt, erzählt etwas anderes als einer, der gerade gesprungen ist. Die nächste ärztliche Kontrolle ist der richtige Ort, ihn einzuordnen.",
en: "A lab value reads first against your own previous draws — a reference range is a coarse anchor, not a verdict. One that has barely moved across several draws tells a different story from one that has just stepped. Your next check-up is the right place to put it in context.",
fr: "Une valeur de laboratoire se lit d’abord contre tes propres prélèvements précédents — un intervalle de référence est un repère grossier, pas un verdict. Une valeur qui a à peine bougé sur plusieurs prélèvements raconte autre chose qu’une valeur qui vient de faire un saut. Ton prochain contrôle médical est le bon endroit pour la remettre en contexte.",
es: "Un valor de laboratorio se lee primero frente a tus propias extracciones anteriores — un intervalo de referencia es un ancla aproximada, no un veredicto. Uno que apenas se ha movido a lo largo de varias extracciones cuenta algo distinto de uno que acaba de dar un salto. Tu próxima revisión médica es el lugar adecuado para ponerlo en contexto.",
it: "Un valore di laboratorio si legge prima di tutto rispetto ai tuoi prelievi precedenti — un intervallo di riferimento è un ancoraggio grossolano, non un verdetto. Uno che si è mosso appena nell’arco di più prelievi racconta una storia diversa da uno che ha appena fatto un salto. Il tuo prossimo controllo medico è il posto giusto per contestualizzarlo.",
pl: "Wynik laboratoryjny czyta się najpierw na tle twoich wcześniejszych oznaczeń — zakres referencyjny to zgrubna kotwica, a nie wyrok. Wynik, który przez kilka oznaczeń niemal się nie ruszył, mówi co innego niż taki, który właśnie podskoczył. Najbliższa kontrola lekarska to właściwe miejsce, żeby go osadzić w kontekście.",
})}`;
}
export function getNoKeyBloodPressureStatusText(
@@ -352,11 +555,14 @@ export function getNoKeyBloodPressureStatusText(
): string {
return (
composeGroundedFallback(signal, BLOOD_PRESSURE_COPY, locale) ??
floor(
locale,
"Blutdruck wird lesbar über eine Reihe ruhiger Messungen unter vergleichbaren Bedingungen — die Richtung über mehrere Tage sagt weit mehr als ein einzelner Ausreißer, und systolisch und diastolisch gehören dabei zusammen gelesen.",
"Blood pressure becomes readable over a run of calm readings under similar conditions — the direction across several days says far more than any single outlier, and systolic and diastolic are read together rather than one at a time.",
)
floor(locale, {
de: "Blutdruck wird lesbar über eine Reihe ruhiger Messungen unter vergleichbaren Bedingungen — die Richtung über mehrere Tage sagt weit mehr als ein einzelner Ausreißer, und systolisch und diastolisch gehören dabei zusammen gelesen.",
en: "Blood pressure becomes readable over a run of calm readings under similar conditions — the direction across several days says far more than any single outlier, and systolic and diastolic are read together rather than one at a time.",
fr: "La tension se lit sur une série de mesures au calme dans des conditions comparables — la direction sur plusieurs jours dit bien plus qu’une valeur isolée, et la systolique et la diastolique se lisent ensemble plutôt que l’une après l’autre.",
es: "La tensión se lee a lo largo de una serie de mediciones en calma y en condiciones comparables — la dirección a lo largo de varios días dice mucho más que cualquier valor aislado, y la sistólica y la diastólica se leen juntas y no una por una.",
it: "La pressione si legge su una serie di misurazioni a riposo in condizioni confrontabili — la direzione nell’arco di più giorni dice molto più di un singolo valore isolato, e sistolica e diastolica si leggono insieme anziché una alla volta.",
pl: "Ciśnienie staje się czytelne dopiero w serii spokojnych pomiarów w porównywalnych warunkach — kierunek z kilku dni mówi znacznie więcej niż pojedynczy wyskok, a wartość skurczowa i rozkurczowa czyta się razem, a nie osobno.",
})
);
}
@@ -366,11 +572,14 @@ export function getNoKeyWeightStatusText(
): string {
return (
composeGroundedFallback(signal, WEIGHT_COPY, locale) ??
floor(
locale,
"Gewicht liest sich als Verlauf, nicht als Tageszahl — unter konstanten Bedingungen gewogen, pendelt sich die normale Tagesschwankung von selbst ein. Die Richtung über Wochen, zusammen mit Blutdruck und BMI gelesen, ist das, was trägt.",
"Weight reads as a trend rather than a daily number — weighed under consistent conditions, the normal day-to-day swing settles out on its own. The direction over weeks, read alongside blood pressure and BMI, is what carries.",
)
floor(locale, {
de: "Gewicht liest sich als Verlauf, nicht als Tageszahl — unter konstanten Bedingungen gewogen, pendelt sich die normale Tagesschwankung von selbst ein. Die Richtung über Wochen, zusammen mit Blutdruck und BMI gelesen, ist das, was trägt.",
en: "Weight reads as a trend rather than a daily number — weighed under consistent conditions, the normal day-to-day swing settles out on its own. The direction over weeks, read alongside blood pressure and BMI, is what carries.",
fr: "Le poids se lit comme une tendance et non comme un chiffre du jour — pesé dans des conditions constantes, la fluctuation quotidienne normale se lisse d’elle-même. C’est la direction sur des semaines, lue avec la tension et l’IMC, qui porte.",
es: "El peso se lee como una tendencia y no como una cifra diaria — pesado en condiciones constantes, la fluctuación normal del día a día se compensa sola. Lo que sostiene es la dirección a lo largo de semanas, leída junto a la tensión y el IMC.",
it: "Il peso si legge come andamento e non come numero del giorno — pesato in condizioni costanti, la normale oscillazione quotidiana si compensa da sé. Quello che regge è la direzione nell’arco di settimane, letta insieme alla pressione e all’IMC.",
pl: "Wagę czyta się jako przebieg, a nie jako liczbę z jednego dnia — przy ważeniu w stałych warunkach normalne dobowe wahania same się wyrównają. Nośny jest kierunek z kilku tygodni, czytany razem z ciśnieniem i BMI.",
})
);
}
@@ -380,11 +589,14 @@ export function getNoKeyPulseStatusText(
): string {
return (
composeGroundedFallback(signal, PULSE_COPY, locale) ??
floor(
locale,
"Der Ruhepuls liest sich am besten entspannt und zur gleichen Tageszeit gemessen. Kurze Ausschläge sind gewöhnlich; was auffällt, ist ein Muster, das über mehrere Tage hält, oder wiederholtes Abdriften vom eigenen üblichen Bereich.",
"Resting pulse reads best taken relaxed and at the same time of day. Short spikes are ordinary; what stands out is a pattern holding across several days, or repeated drift away from your usual range.",
)
floor(locale, {
de: "Der Ruhepuls liest sich am besten entspannt und zur gleichen Tageszeit gemessen. Kurze Ausschläge sind gewöhnlich; was auffällt, ist ein Muster, das über mehrere Tage hält, oder wiederholtes Abdriften vom eigenen üblichen Bereich.",
en: "Resting pulse reads best taken relaxed and at the same time of day. Short spikes are ordinary; what stands out is a pattern holding across several days, or repeated drift away from your usual range.",
fr: "Le pouls au repos se lit au mieux pris détendu et à la même heure de la journée. Les à-coups brefs sont ordinaires ; ce qui ressort, c’est un motif qui tient sur plusieurs jours, ou une dérive répétée hors de tes repères habituels.",
es: "El pulso en reposo se lee mejor tomado relajado y a la misma hora del día. Los picos breves son corrientes; lo que destaca es un patrón que se mantiene varios días, o una deriva repetida fuera de tu rango habitual.",
it: "Il battito a riposo si legge meglio se misurato da rilassato e alla stessa ora del giorno. I picchi brevi sono ordinari; quello che spicca è uno schema che tiene per più giorni, o uno scostamento ripetuto dal tuo intervallo abituale.",
pl: "Tętno spoczynkowe najlepiej czytać zmierzone na spokojnie i o tej samej porze dnia. Krótkie skoki są czymś zwyczajnym; uwagę zwraca wzorzec utrzymujący się przez kilka dni albo powtarzające się oddalanie od twojego zwykłego zakresu.",
})
);
}
@@ -394,11 +606,14 @@ export function getNoKeyBmiStatusText(
): string {
return (
composeGroundedFallback(signal, BMI_COPY, locale) ??
floor(
locale,
"Der BMI ist eine grobe Orientierung, kein Urteil, und bedeutet am meisten zusammen mit Gewichtstrend und Körperzusammensetzung gelesen. Eine anhaltende Bewegung über Wochen trägt, wo ein Einzelwert es nicht tut.",
"BMI is a rough orientation rather than a verdict, and it means most read alongside your weight trend and body composition. Sustained movement over weeks carries where a single value does not.",
)
floor(locale, {
de: "Der BMI ist eine grobe Orientierung, kein Urteil, und bedeutet am meisten zusammen mit Gewichtstrend und Körperzusammensetzung gelesen. Eine anhaltende Bewegung über Wochen trägt, wo ein Einzelwert es nicht tut.",
en: "BMI is a rough orientation rather than a verdict, and it means most read alongside your weight trend and body composition. Sustained movement over weeks carries where a single value does not.",
fr: "L’IMC est une orientation grossière plutôt qu’un verdict, et il prend son sens lu avec la tendance de ton poids et ta composition corporelle. Un mouvement soutenu sur des semaines porte là où une valeur isolée ne porte pas.",
es: "El IMC es una orientación aproximada más que un veredicto, y cobra sentido leído junto a la tendencia de tu peso y tu composición corporal. Un movimiento sostenido a lo largo de semanas sostiene donde un valor aislado no lo hace.",
it: "L’IMC è un orientamento grossolano più che un verdetto, e assume senso letto insieme all’andamento del tuo peso e alla composizione corporea. Un movimento sostenuto nell’arco di settimane regge dove un valore singolo non regge.",
pl: "BMI to zgrubna orientacja, a nie wyrok, i nabiera sensu dopiero czytane razem z tendencją wagi i składem ciała. Utrzymujący się ruch w skali tygodni jest nośny tam, gdzie pojedyncza wartość nie jest.",
})
);
}
@@ -408,11 +623,14 @@ export function getNoKeyMedicationComplianceStatusText(
): string {
return (
composeGroundedFallback(signal, ADHERENCE_COPY, locale) ??
floor(
locale,
"Einnahmetreue liest sich als Konstanz über Wochen, nicht als eine Reihe perfekter Tage — je Medikament und im Gesamtbild. Wiederkehrende Auslassungen zur selben Tageszeit sind das Muster, auf das es ankommt, und eine feste Routine fängt sie am ehesten ab.",
"Adherence reads as consistency over weeks rather than a run of perfect days — per medication and in the overall picture. Repeated misses at the same time of day are the pattern that counts, and a fixed routine is what usually catches them.",
)
floor(locale, {
de: "Einnahmetreue liest sich als Konstanz über Wochen, nicht als eine Reihe perfekter Tage — je Medikament und im Gesamtbild. Wiederkehrende Auslassungen zur selben Tageszeit sind das Muster, auf das es ankommt, und eine feste Routine fängt sie am ehesten ab.",
en: "Adherence reads as consistency over weeks rather than a run of perfect days — per medication and in the overall picture. Repeated misses at the same time of day are the pattern that counts, and a fixed routine is what usually catches them.",
fr: "L’observance se lit comme une régularité sur des semaines plutôt que comme une série de journées parfaites — par médicament et dans l’ensemble. Les oublis répétés à la même heure sont le motif qui compte, et c’est une routine fixe qui les rattrape d’ordinaire.",
es: "La adherencia se lee como constancia a lo largo de semanas y no como una racha de días perfectos — por medicamento y en el conjunto. Los olvidos repetidos a la misma hora son el patrón que cuenta, y lo que suele recuperarlos es una rutina fija.",
it: "L’aderenza si legge come costanza nell’arco di settimane più che come una serie di giornate perfette — per singolo farmaco e nel quadro complessivo. Le dimenticanze ripetute alla stessa ora sono lo schema che conta, ed è una routine fissa a intercettarle di solito.",
pl: "Regularność przyjmowania leków czyta się jako stałość w skali tygodni, a nie jako serię idealnych dni — osobno dla każdego leku i w całym obrazie. Powtarzające się pominięcia o tej samej porze to wzorzec, który się liczy, a wyłapuje je zwykle stały rytm dnia.",
})
);
}
@@ -422,10 +640,13 @@ export function getNoKeyMoodStatusText(
): string {
return (
composeGroundedFallback(signal, MOOD_COPY, locale) ??
floor(
locale,
"Stimmung liest sich über Wochen, nicht über einzelne Tage — wiederkehrende Muster und wie sie zu den anderen Werten passen, sind das, was trägt. Eine anhaltende Phase niedriger Stimmung ist die, die Aufmerksamkeit verdient.",
"Mood reads over weeks rather than single days — recurring patterns, and how they line up with your other metrics, are what carry. A sustained low stretch is the one that deserves attention.",
)
floor(locale, {
de: "Stimmung liest sich über Wochen, nicht über einzelne Tage — wiederkehrende Muster und wie sie zu den anderen Werten passen, sind das, was trägt. Eine anhaltende Phase niedriger Stimmung ist die, die Aufmerksamkeit verdient.",
en: "Mood reads over weeks rather than single days — recurring patterns, and how they line up with your other metrics, are what carry. A sustained low stretch is the one that deserves attention.",
fr: "L’humeur se lit sur des semaines plutôt que sur des journées isolées — ce qui porte, ce sont les motifs qui reviennent et la façon dont ils s’alignent avec tes autres valeurs. C’est une phase basse qui dure qui mérite l’attention.",
es: "El estado de ánimo se lee a lo largo de semanas y no en días sueltos — lo que sostiene son los patrones que se repiten y cómo encajan con tus otros valores. La que merece atención es una fase baja que se prolonga.",
it: "L’umore si legge nell’arco di settimane più che nei singoli giorni — quello che regge sono gli schemi ricorrenti e come si allineano con gli altri tuoi valori. È una fase bassa che si protrae quella che merita attenzione.",
pl: "Nastrój czyta się w skali tygodni, a nie pojedynczych dni — nośne są powtarzające się wzorce i to, jak układają się względem twoich pozostałych wartości. Uwagi wymaga przede wszystkim dłużej utrzymujący się spadek.",
})
);
}
@@ -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();
});
});
+4
View File
@@ -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,
+6 -4
View File
@@ -417,13 +417,15 @@ const sleepDebtResource = z.object({
state: z
.enum(["partial", "ready"])
.describe(
"`partial` until enough tracked nights exist (calm 'still learning'); `ready` once the rolling window can assert a cumulative debt.",
"`partial` until enough tracked nights exist (calm 'still learning'); `ready` once the rolling window can assert a balance.",
),
debtMinutes: z
.number()
.int()
.nonnegative()
.describe("Cumulative sleep debt over the window, in minutes, after caps."),
.describe(
"Outstanding sleep-debt balance over the window, in minutes, after caps. A night below need adds its shortfall; a night above need pays the balance down (floored at 0).",
),
needMinutes: z
.number()
.int()
@@ -513,7 +515,7 @@ const sleepRhythmResponse = z
.meta({
id: "SleepRhythmResource",
description:
"Server-authoritative sleep-rhythm read: cumulative sleep debt over the rolling window + MCTQ chronotype (MSF/MSFsc band, social jetlag). Free vs work nights default to weekend = free in the user's timezone (no work calendar). A view over existing per-stage SLEEP_DURATION rows — no schema, no new type.",
"Server-authoritative sleep-rhythm read: the outstanding sleep-debt balance over the rolling window + MCTQ chronotype (MSF/MSFsc band, social jetlag). Free vs work nights default to weekend = free in the user's timezone (no work calendar). A view over existing per-stage SLEEP_DURATION rows — no schema, no new type.",
});
// ── Time-series adapter (iOS chart source) ───────────────────────────
@@ -1004,7 +1006,7 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Measurements"],
summary: "Sleep rhythm — sleep-debt + chronotype (v1.17.0)",
description:
"Returns the two server-authoritative sleep-timing signals the Sleep page + iOS render off the same canonical night reconstruction the Sleep Score uses: cumulative `sleepDebt` over the rolling window (calm `partial` state under the night threshold) and MCTQ `chronotype` (MSF/MSFsc band + social jetlag, `learning` state until enough free-day nights exist). Free vs work nights default to weekend = free in the user's timezone (no work calendar). A read-only view over existing per-stage SLEEP_DURATION rows — no schema, no new measurement type.",
"Returns the two server-authoritative sleep-timing signals the Sleep page + iOS render off the same canonical night reconstruction the Sleep Score uses: the outstanding `sleepDebt` balance over the rolling window (calm `partial` state under the night threshold) and MCTQ `chronotype` (MSF/MSFsc band + social jetlag, `learning` state until enough free-day nights exist). Free vs work nights default to weekend = free in the user's timezone (no work calendar). A read-only view over existing per-stage SLEEP_DURATION rows — no schema, no new measurement type.",
responses: {
"200": {
description: "Sleep-debt + chronotype DTO.",
@@ -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 }),
+8
View File
@@ -21,6 +21,14 @@ export const settingsKeys = {
/** v1.8.5 — user-level injection-site preferences (global exclusion). */
injectionSitePrefs: () => ["settings", "injection-site-prefs"] as const,
/**
* The declared diabetes opt-in behind `GET/PATCH /api/auth/me/diabetes`.
* Selects which reference band glucose readings are judged against, so the
* toggle also invalidates `insightsTargets()` — the resolved band rides
* on that payload.
*/
diabetesPref: () => ["settings", "diabetes"] as const,
/**
* v1.18.0 — the per-user module enable/disable map behind
* `GET/PATCH /api/auth/me/modules`. Used as the Module-hub mutation key;
@@ -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({